Clean up code. Reorganize files. Port stuff from other branches. + more

This turns the project into a monorepo using pnpm workspaces,
dramatically simplifying the build process. It also fixes a lot of bugs
and just generally makes the codebase a lot cleaner.
This commit is contained in:
Zoe
2025-12-04 18:48:00 -06:00
parent cfab3d0b8f
commit 9ba5b12dac
38 changed files with 10459 additions and 1180 deletions

View File

@@ -2058,7 +2058,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2332,7 +2331,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2383,7 +2381,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -36,4 +36,4 @@
"dependencies": {
"uuidv7": "^1.0.2"
}
}
}

View File

@@ -1,4 +1,5 @@
import WASMSolverUrl from '../../../solver/zig-out/bin/solver.wasm?url&inline';
import WASMSolverUrl from '../../solver/zig-out/bin/solver.wasm?url&inline';
import { ChallengeStrategy, type Challenge, type ChallengeLeadingZeroes, type ChallengeTargetNumber } from '.';
type WasmExports = Record<string, Function> & {
"malloc": (byte_count: number) => number | null;
@@ -17,6 +18,7 @@ export type SolverEnv = {
__set_solution: (value: number) => void;
__cmpxchg_solution: (expected: number, replacement: number) => number;
__fetch_add_nonce: (value: number) => number;
__log: (str_ptr: number, str_len: number) => void;
};
export async function get_wasm_module(): Promise<WebAssembly.Module> {
@@ -29,7 +31,18 @@ export async function init_solver(env: SolverEnv, module: WebAssembly.Module): P
}) as unknown as SolverModule;
}
export function solve_leaading_zeroes_challenge(solver: SolverModule, challenge: { salt: string, difficulty: number }): number {
export function solve_challenge(solver: SolverModule, challenge: Challenge): number {
switch (challenge.strategy) {
case ChallengeStrategy.LeadingZeroes:
return solve_leaading_zeroes_challenge(solver, challenge);
case ChallengeStrategy.TargetNumber:
return solve_target_number_challenge(solver, challenge);
default:
throw new Error("Invalid challenge strategy");
}
}
function solve_leaading_zeroes_challenge(solver: SolverModule, challenge: ChallengeLeadingZeroes): number {
const { salt, difficulty } = challenge;
const encoder = new TextEncoder();
@@ -56,7 +69,7 @@ export function solve_leaading_zeroes_challenge(solver: SolverModule, challenge:
return ret;
}
export function solve_target_number_challenge(solver: SolverModule, challenge: { salt: string, target: string }): number {
function solve_target_number_challenge(solver: SolverModule, challenge: ChallengeTargetNumber): number {
const { salt, target } = challenge;
const encoder = new TextEncoder();

View File

@@ -1,5 +1,5 @@
import { ChallengeAlgorithm, ChallengeStrategy, type Challenge } from '.';
import WASMValidatorUrl from '../../../solver/zig-out/bin/validator.wasm?url&inline';
import { ChallengeAlgorithm, ChallengeStrategy, type Challenge, type ChallengeLeadingZeroes, type ChallengeTargetNumber } from '.';
import WASMValidatorUrl from '../../solver/zig-out/bin/validator.wasm?url&inline';
type WasmExports = Record<string, Function> & {
"malloc": (byte_count: number) => number | null;
@@ -18,11 +18,11 @@ function array_to_base64(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
async function generate_leading_zeroes_challenge(parameters: Object, difficulty: number): Promise<Challenge> {
async function generate_leading_zeroes_challenge(parameters: Object, difficulty: number): Promise<ChallengeLeadingZeroes> {
let parameters_str = Object.entries(parameters).map(([key, value]) => `${key}=${value}`).join("&");
let salt = `${array_to_base64(crypto.getRandomValues(new Uint8Array(32)).buffer)}?${parameters_str}`;
let challenge: Challenge = {
let challenge: ChallengeLeadingZeroes = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.LeadingZeroes,
salt,
@@ -32,7 +32,7 @@ async function generate_leading_zeroes_challenge(parameters: Object, difficulty:
return challenge;
}
async function generate_target_number_challenge(parameters: Object, max_number: number): Promise<Challenge | null> {
async function generate_target_number_challenge(parameters: Object, max_number: number): Promise<ChallengeTargetNumber | null> {
// in target number config, since we need to generate a target hash, we
// need to hash the salt + nonce, so the client knows what the target is
const validator = (await WebAssembly.instantiateStreaming(fetch(WASMValidatorUrl))).instance as unknown as ValidatorModule;
@@ -68,7 +68,7 @@ async function generate_target_number_challenge(parameters: Object, max_number:
let target_slice = new Uint8Array(validator.exports.memory.buffer.slice(target_ptr, target_ptr + target_len));
const target = new TextDecoder().decode(target_slice);
let challenge: Challenge = {
let challenge: ChallengeTargetNumber = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.TargetNumber,
salt,

View File

@@ -18,7 +18,7 @@ export default defineConfig({
preserveModules: false
}
},
sourcemap: true
minify: true
},
resolve: {
alias: {

2
packages/solver/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.zig-cache/
zig-out/

46
packages/solver/build.zig Normal file
View File

@@ -0,0 +1,46 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const target = b.standardTargetOptions(.{});
// solver
const solver_mod = b.addModule("solver", .{
.root_source_file = b.path("src/solver.zig"),
.optimize = optimize,
.target = target,
});
const solver_exe = b.addExecutable(.{
.name = "solver",
.root_module = solver_mod,
});
if (target.result.cpu.arch == .wasm32) {
solver_exe.entry = .disabled;
solver_exe.rdynamic = true;
solver_exe.link_gc_sections = true;
solver_exe.lto = .full;
}
b.installArtifact(solver_exe);
// validator
const validator_mod = b.addModule("validator", .{
.root_source_file = b.path("src/validator.zig"),
.optimize = optimize,
.target = target,
});
const validator_exe = b.addExecutable(.{
.name = "validator",
.root_module = validator_mod,
});
if (target.result.cpu.arch == .wasm32) {
validator_exe.entry = .disabled;
validator_exe.rdynamic = true;
}
b.installArtifact(validator_exe);
}

View File

@@ -0,0 +1,11 @@
{
"name": "solver",
"description": "Zig WASM POW solver, not an actual node package, just using this so that pnpm will build it for me",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "pnpm run build:wasm && pnpm run minify",
"build:wasm": "zig build --release=fast -Dtarget=wasm32-freestanding -Dcpu=generic+bulk_memory+bulk_memory_opt+simd128+tail_call",
"minify": "wasm-opt --strip-debug --strip-dwarf -O4 -o zig-out/bin/solver.wasm zig-out/bin/solver.wasm && wasm-opt --strip-debug --strip-dwarf -O4 -o zig-out/bin/validator.wasm zig-out/bin/validator.wasm"
}
}

View File

@@ -0,0 +1,18 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
var argon2_params = std.crypto.pwhash.argon2.Params{
.t = 3, // time cost
.m = 8192, // memory cost (in KiB)
.p = 1, // parallelism (this doesnt do anything because we are targeting wasm, and we do multithreading differently anyways)
};
const dk_len: usize = 32; // 16 or 32 byte key
var derived: [dk_len]u8 = undefined;
var buffer_hash_hex: [64]u8 = undefined;
pub fn hash(allocator: Allocator, challenge: []const u8, nonce: []const u8) ![]u8 {
try std.crypto.pwhash.argon2.kdf(allocator, &derived, nonce, challenge, argon2_params, .argon2id);
return derived[0..];
}

View File

@@ -0,0 +1,155 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const utils = @import("utils.zig");
const argon2 = @import("argon2.zig");
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var allocator = gpa.allocator();
extern fn __get_solution() i32;
extern fn __set_solution(value: i32) void;
extern fn __cmpxchg_solution(old: i32, new: i32) i32;
extern fn __fetch_add_nonce(value: i32) i32;
extern fn __log(str_ptr: usize, str_len: usize) void;
fn log(comptime level: std.log.Level, comptime scope: @TypeOf(.EnumLiteral), comptime fmt: []const u8, args: anytype) void {
if (comptime builtin.target.cpu.arch != .wasm32) {
std.log.defaultLog(level, scope, fmt, args);
return;
}
const log_level_str = switch (level) {
.err => "Error: ",
.warn => "Warning: ",
.info => "Info: ",
.debug => "Debug: ",
};
const formatted = std.fmt.allocPrint(allocator, fmt, args) catch return;
const log_str = std.fmt.allocPrint(allocator, "{s}{s}", .{ log_level_str, formatted }) catch return;
allocator.free(formatted);
__log(@intFromPtr(log_str.ptr), log_str.len);
allocator.free(log_str);
}
pub const std_options: std.Options = .{ .logFn = log };
var hex_encoder = utils.HexEncoder{};
export fn malloc(byte_count: usize) ?*u8 {
const ptr = allocator.alloc(u8, byte_count) catch return null;
return @ptrCast(ptr.ptr);
}
export fn free(ptr: ?*anyopaque, byte_count: usize) void {
if (ptr) |p| {
const cast_ptr: [*]u8 = @ptrCast(p);
allocator.free(cast_ptr[0..byte_count]);
}
}
// returns nonce on success, -1 on failure
// to get the error, call get_solve_error
export fn solve_leaading_zeroes_challenge(challenge_ptr: [*]u8, challenge_len: usize, difficulty: u32) i32 {
const challenge_slice = challenge_ptr[0..challenge_len];
if (difficulty < 1 or difficulty > 64) {
std.log.err("Invalid difficulty for leading zeroes\n", .{});
return -1;
}
const max_nonce_iterations: u64 = 1_000_000_000;
// 64 + 9 digits for nonce since the max nonce is 999_999_999 (not 1 billion since nonce < max_nonce_iterations)
var input_buffer: []u8 = allocator.alloc(u8, challenge_len + 9) catch {
std.log.err("Failed to allocate memory for challenge\n", .{});
return -1;
};
// dont leak memory :pepega:
defer allocator.free(input_buffer);
@memcpy(input_buffer[0..challenge_len], challenge_slice);
var nonce = __fetch_add_nonce(1);
while (nonce < max_nonce_iterations) : (nonce = __fetch_add_nonce(1)) {
if (__get_solution() != -1) {
// solution has already been found, no point in continuing
return 0;
}
const nonce_str = std.fmt.bufPrint(input_buffer[challenge_len..], "{d}", .{nonce}) catch {
std.log.err("Failed to allocate memory for nonce\n", .{});
return -1;
};
const argon2_key = argon2.hash(allocator, input_buffer[0..challenge_len], input_buffer[challenge_len .. challenge_len + nonce_str.len]) catch {
std.log.err("Failed to hash argon2 key\n", .{});
return -1;
};
_ = hex_encoder.encode(argon2_key);
if (!hex_encoder.countZeroes(difficulty)) {
continue;
}
// Found a solution!
if (__cmpxchg_solution(-1, nonce) == -1) {
// we found a solution, and we are the first to do so
return nonce;
} else {
// we found a solution, but we are not the first to do so
return 0;
}
}
return -1;
}
export fn solve_target_number_challenge(target_ptr: [*]u8, target_len: usize, salt_ptr: [*]u8, salt_len: usize) i32 {
const target_slice = target_ptr[0..target_len];
const salt_slice = salt_ptr[0..salt_len];
// TODO: take in max number
const max_nonce_iterations: usize = 1_000_000_000;
const max_digits = std.math.log10(max_nonce_iterations);
var input_buffer: []u8 = allocator.alloc(u8, salt_len + max_digits) catch {
return -1;
};
defer allocator.free(input_buffer);
@memcpy(input_buffer[0..salt_len], salt_slice);
var nonce = __fetch_add_nonce(1);
while (nonce < max_nonce_iterations) : (nonce = __fetch_add_nonce(1)) {
if (__get_solution() != -1) {
return 0;
}
const nonce_str = std.fmt.bufPrint(input_buffer[salt_len..], "{d}", .{nonce}) catch {
return -1;
};
const argon2_key = argon2.hash(allocator, input_buffer[0..salt_len], input_buffer[salt_len .. salt_len + nonce_str.len]) catch {
return -1;
};
const hex_slice = hex_encoder.encode(argon2_key);
if (std.mem.eql(u8, target_slice, hex_slice)) {
// Found a solution!
if (__cmpxchg_solution(-1, nonce) == -1) {
// we found a solution, and we are the first to do so
return nonce;
} else {
// we found a solution, but we are not the first to do so
return 0;
}
}
}
return -1;
}

View File

@@ -0,0 +1,43 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const HexEncoder = struct {
scratch: [64]u8 = undefined,
scratch_set: bool = false,
const Self = @This();
pub fn encode(self: *Self, bytes: []const u8) []u8 {
self.scratch_set = true;
const hex_chars = "0123456789abcdef";
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
self.scratch[i * 2] = hex_chars[(bytes[i] >> 4)];
self.scratch[i * 2 + 1] = hex_chars[bytes[i] & 0x0F];
}
return &self.scratch;
}
// counts the number of leading hexidecimal zeroes in the scratch buffer
// which is set by encode
pub fn countZeroes(self: *Self, zeroes: usize) bool {
if (!self.scratch_set) {
return false;
}
if (zeroes > 64 or zeroes == 0) {
return false;
}
var i: usize = 0;
while (i < zeroes) : (i += 1) {
if (self.scratch[i] != '0') {
return false;
}
}
return true;
}
};

View File

@@ -0,0 +1,76 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const argon2 = @import("argon2.zig");
const utils = @import("utils.zig");
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var allocator = gpa.allocator();
var hex_encoder = utils.HexEncoder{};
export fn malloc(byte_count: usize) ?*u8 {
const ptr = allocator.alloc(u8, byte_count) catch return null;
return @ptrCast(ptr.ptr);
}
export fn free(ptr: ?*anyopaque, byte_count: usize) void {
if (ptr) |p| {
const cast_ptr: [*]u8 = @ptrCast(p);
allocator.free(cast_ptr[0..byte_count]);
}
}
export fn validate_leading_zeroes_challenge(challenge_ptr: [*]u8, challenge_len: usize, nonce_ptr: [*]u8, nonce_len: usize, difficulty: u32) i32 {
const challenge_slice = challenge_ptr[0..challenge_len];
const nonce_slice = nonce_ptr[0..nonce_len];
if (difficulty < 1 or difficulty > 64) {
return -1;
}
const argon2_key = argon2.hash(allocator, challenge_slice, nonce_slice) catch return -2;
_ = hex_encoder.encode(argon2_key);
if (!hex_encoder.countZeroes(difficulty)) {
return -3;
}
return 0;
}
export fn validate_target_number_challenge(target_ptr: [*]u8, target_len: usize, nonce_ptr: [*]u8, nonce_len: usize, salt_ptr: [*]u8, salt_len: usize) i32 {
const target_slice = target_ptr[0..target_len];
const salt_slice = salt_ptr[0..salt_len];
const nonce_slice = nonce_ptr[0..nonce_len];
const argon2_key = argon2.hash(allocator, salt_slice, nonce_slice) catch return -2;
const hex_slice = hex_encoder.encode(argon2_key);
if (!std.mem.eql(u8, target_slice, hex_slice)) {
return -3;
}
return 0;
}
export fn hash(challenge_ptr: [*]u8, challenge_len: usize, nonce_ptr: [*]u8, nonce_len: usize) u64 {
const challenge = challenge_ptr[0..challenge_len];
const nonce = nonce_ptr[0..nonce_len];
const argon2_key = argon2.hash(allocator, challenge, nonce) catch return 0;
const hex_slice = hex_encoder.encode(argon2_key);
// bs to get the compiler to not whine about hash_slice.len being a u5 annd thus cannot be shifted by 32
var ret: u64 = hex_slice.len;
ret <<= 32;
ret |= @intFromPtr(hex_slice.ptr);
return ret;
}
// pub fn main() void {
// const challenge = "4d7220e22a1ea588fea60000ab8874194e4c6ffd71077adbae915826c73dbf48";
// const nonce = "4302";
// const difficulty = 3;
// std.log.info("{d}", .{validate_challenge(@constCast(challenge[0..].ptr), challenge.len, @constCast(nonce[0..].ptr), nonce.len, difficulty)});
// }

View File

@@ -8,6 +8,9 @@
"name": "@impost/widget",
"version": "0.1.0",
"license": "BSL-1.0",
"dependencies": {
"comlink": "^4.4.2"
},
"devDependencies": {
"@types/node": "^20.11.24",
"lit": "^3.1.2",
@@ -1433,7 +1436,6 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1655,6 +1657,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="
},
"node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
@@ -2148,7 +2155,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2422,7 +2428,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2461,7 +2466,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -23,6 +23,7 @@
"lit-element": "^3.1.2"
},
"devDependencies": {
"@impost/lib": "workspace:*",
"@types/node": "^20.11.24",
"lit": "^3.1.2",
"lit-element": "^3.1.2",
@@ -30,6 +31,7 @@
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-dts": "^4.5.4"
"vite-plugin-dts": "^4.5.4",
"comlink": "^4.4.2"
}
}

View File

@@ -1,13 +1,15 @@
import { LitElement, html, css, isServer, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { type ChallengeSolveRequest, type SolutionMessage, WorkerMessageType, type WorkerRequest, WorkerResponseType } from './types/worker';
import { type Challenge, ChallengeStrategy } from '@impost/lib';
import { type Challenge } from '@impost/lib';
import { get_wasm_module } from '@impost/lib/solver';
import * as Comlink from 'comlink';
import ChallengeWorker from './solver-worker?worker&inline';
import SolverWorker from './solver-worker?worker&inline';
@customElement('pow-captcha')
export class PowCaptcha extends LitElement {
type SolverWorkerAPI = Comlink.Remote<typeof import("./solver-worker")>;
@customElement('impost-captcha')
export class ImpostCaptcha extends LitElement {
static override styles = css`
:host {
display: block;
@@ -63,13 +65,13 @@ export class PowCaptcha extends LitElement {
.impost-footer div a:hover {
text-decoration: none;
}
.hidden {
display: none;
}
`;
/// ================================================
/// Configuration
/// ================================================
// one of: "load", "focus", "submit", "off"
@property({ type: String })
auto: "onload" | "onfocus" | "onsubmit" | "off" = "off";
@@ -79,24 +81,28 @@ export class PowCaptcha extends LitElement {
@property({ type: String })
challengejson: string = '';
@property({ type: Boolean })
showHashrate: boolean = false;
/// ================================================
/// Internals
/// ================================================
// needed to allow for multiple widgets on the same page if you wanted to
// do that for some reason
uid: string = Math.floor(Math.random() * 100000).toString();
private uid: string = Math.floor(Math.random() * 100000).toString();
private _internals: ElementInternals | null = null;
static formAssociated = true;
@state()
private solution: string = '';
@state()
private errorMessage: string = '';
private solution: string | null = null;
@state()
private challengeData: Challenge | null = null;
@state()
private solved: boolean = false;
@state()
private isSolving: boolean = false;
private status: 'unsolved' | 'solving' | 'solved' | 'error' = 'unsolved';
@state()
private disabled: boolean = true;
@@ -104,18 +110,23 @@ export class PowCaptcha extends LitElement {
@state()
private hashRate: number = 0;
// stores the nonce and solution atomics
private sab: SharedArrayBuffer = new SharedArrayBuffer(8);
private solverWorkers: Worker[] | null = null;
private solverWorkers: SolverWorkerAPI[] | null = null;
private nativeWorkers: Worker[] | null = null;
private solveStartTime: number | null = null;
private hashRateInterval: number | null = null;
override connectedCallback() {
super.connectedCallback();
this._internals = this.attachInternals();
this.fetchChallenge();
console.log(this._internals.form);
this.initWorkers();
switch (this.auto) {
@@ -127,7 +138,23 @@ export class PowCaptcha extends LitElement {
break;
case 'onsubmit':
if (this.parentElement?.nodeName === 'FORM') {
this.parentElement.addEventListener('submit', () => this.solveChallenge());
this.parentElement.addEventListener('submit', async (ev) => {
if (this.status === 'solved') {
return;
}
ev.preventDefault();
await this.solveChallenge();
const form = this.parentElement as HTMLFormElement;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
});
}
break;
}
@@ -146,9 +173,10 @@ export class PowCaptcha extends LitElement {
this.hashRateInterval = null;
}
for (const worker of this.solverWorkers || []) {
for (const worker of this.nativeWorkers || []) {
worker.terminate();
this.solverWorkers = null;
this.nativeWorkers = null;
}
}
@@ -157,7 +185,6 @@ export class PowCaptcha extends LitElement {
}
async fetchChallenge() {
this.errorMessage = '';
if (this.challengeData !== null) {
return;
}
@@ -168,6 +195,7 @@ export class PowCaptcha extends LitElement {
return;
}
// challenge data must be provided by the user when using SSR
if (isServer) {
return;
}
@@ -179,16 +207,21 @@ export class PowCaptcha extends LitElement {
})
.catch(error => {
console.error('Error fetching challenge:', error);
this.errorMessage = 'Failed to fetch challenge. Please try again.';
this.status = 'error';
});
}
async initWorkers() {
this.solverWorkers = [];
this.nativeWorkers = [];
const num_workers = navigator.hardwareConcurrency;
for (let i = 0; i < num_workers; i++) {
this.solverWorkers.push(new ChallengeWorker());
const nativeWorker = new SolverWorker();
const comlinkWorker = Comlink.wrap<SolverWorkerAPI>(nativeWorker);
this.nativeWorkers.push(nativeWorker);
this.solverWorkers.push(comlinkWorker);
}
const atomics_view = new Int32Array(this.sab);
@@ -198,106 +231,53 @@ export class PowCaptcha extends LitElement {
let wasm_module = await get_wasm_module();
let worker_promises: Promise<void>[] = [];
for (let i = 0; i < this.solverWorkers.length; i++) {
console.log('Worker', i);
const worker = this.solverWorkers[i]!;
worker_promises.push(new Promise<void>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
console.log('Worker', i, 'got message', event.data);
if (event.data.type === WorkerResponseType.Error) {
console.error("Worker error:", event.data.error);
reject(event.data.error);
}
if (event.data.type === WorkerResponseType.InitOk) {
resolve();
}
reject(new Error("Unexpected message from worker"));
};
const error_handler = (error: ErrorEvent) => {
console.error("Worker error:", error);
reject(error);
};
worker.addEventListener('message', message_handler);
worker.addEventListener('error', error_handler);
worker.postMessage({
type: WorkerMessageType.Init,
module: wasm_module,
sab: this.sab,
} as WorkerRequest);
}));
const solver = this.solverWorkers[i]!;
worker_promises.push(solver.init(wasm_module, this.sab)); // Direct call to exposed `init` method
}
const timeoutMs = 10 * 1000;
let timeout: number;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
this.errorMessage = 'Failed to initialize workers in time. Please refresh the page.';
reject(new Error(`Function timed out after ${timeoutMs}ms`));
reject(new Error(`Function timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
await Promise.race([
Promise.allSettled(worker_promises).then(() => {
Promise.allSettled(worker_promises).then(results => {
const failedInits = results.filter(r => r.status === 'rejected');
if (failedInits.length > 0) {
console.error('Some workers failed to initialize:', failedInits);
// we might want to collect all errors, and if every
// worker fails, we can throw, but carry on if only some
// fail. For now, we'll just throw if any fail.
throw new Error("One or more workers failed to initialize.");
}
console.log('All workers initialized');
return;
}),
timeoutPromise,
]).then(() => {
clearTimeout(timeout);
});
}
async issueChallengeToWorker(worker: Worker, request: ChallengeSolveRequest): Promise<SolutionMessage> {
return new Promise<SolutionMessage>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
worker.removeEventListener('message', message_handler);
worker.removeEventListener('error', error_handler);
resolve(event.data);
};
const error_handler = (error: ErrorEvent) => {
worker.removeEventListener('error', error_handler);
worker.removeEventListener('message', message_handler);
console.error("Worker error:", error);
reject(error);
};
worker.addEventListener('message', message_handler);
worker.addEventListener('error', error_handler);
switch (request.strategy) {
case ChallengeStrategy.LeadingZeroes:
worker.postMessage({
strategy: ChallengeStrategy.LeadingZeroes,
salt: request.salt,
difficulty: request.difficulty,
} as WorkerRequest);
break;
case ChallengeStrategy.TargetNumber:
worker.postMessage({
strategy: ChallengeStrategy.TargetNumber,
target: request.target,
salt: request.salt,
} as WorkerRequest);
break;
}
}).catch(error => {
clearTimeout(timeout);
console.error("Worker initialization failed:", error);
this.status = 'error';
throw error;
});
}
async solveChallenge() {
if (!this.challengeData || this.solverWorkers === null) {
this.errorMessage = 'Captcha is not ready. Please wait or refresh.';
// in all normal cases, this should be impossible
this.status = 'error';
return;
}
if (this.solution !== '') {
if (this.solution !== null || this.status !== 'unsolved') {
// do not solve twice
return;
}
@@ -306,45 +286,23 @@ export class PowCaptcha extends LitElement {
const nonce = this.getCurrentWorkingNonce();
this.hashRate = (nonce / ((performance.now() - this.solveStartTime!) / 1000));
console.log(this.hashRate);
}, 250);
this.dispatchEvent(new CustomEvent('impost:solve', {
detail: {
solution: this.solution,
},
detail: { challenge: this.challengeData, }, // empty solution
bubbles: true,
composed: true,
}))
}));
console.log(this.challengeData);
this.isSolving = true;
this.errorMessage = '';
this.solution = '';
this.status = 'solving';
this.solution = null;
const atomics_view = new Int32Array(this.sab);
// reset atomics
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, -1);
let request: ChallengeSolveRequest;
switch (this.challengeData.strategy) {
case ChallengeStrategy.LeadingZeroes:
request = {
strategy: ChallengeStrategy.LeadingZeroes,
salt: this.challengeData.salt,
difficulty: this.challengeData.difficulty,
};
break;
case ChallengeStrategy.TargetNumber:
request = {
strategy: ChallengeStrategy.TargetNumber,
target: this.challengeData.target,
salt: this.challengeData.salt,
};
break;
}
console.log('Sending challenge to workers...');
// TODO: the first response is not always the solution, due to cmpxchg
// blocking, some workers may block on the read, and as soon as they
@@ -352,40 +310,42 @@ export class PowCaptcha extends LitElement {
//
// We need to do a better job of tracking solvers, so if one worker
// errors out, we only error out if all workers have errored out.
let worker_promises: Promise<SolutionMessage>[] = [];
for (let worker of this.solverWorkers) {
// dispatch to all workers, func is async so it will not block
worker_promises.push(this.issueChallengeToWorker(worker, request));
let worker_promises: Promise<string>[] = [];
for (let solver of this.solverWorkers) {
worker_promises.push(solver.solve_challenge(this.challengeData as Challenge));
}
let solution = await Promise.race(worker_promises);
try {
await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
this.errorMessage = solution.error;
return;
// The true solution is stored in the SharedArrayBuffer.
this.solution = String(Atomics.load(atomics_view, 1));
this.status = 'solved';
} catch (error: any) {
console.error("Captcha solving failed:", error);
this.status = 'error';
} finally {
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
}
if (solution.type !== WorkerResponseType.Solution) {
this.errorMessage = "Something went wrong, please try again later.";
return;
}
this.solution = Atomics.load(atomics_view, 1).toString();
this.isSolving = false;
this.solved = true;
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
this.dispatchEvent(new CustomEvent('impost:solved', {
detail: {
if (this.status === 'solved') {
this._internals!.setFormValue(JSON.stringify({
challenge: this.challengeData.salt,
solution: this.solution,
},
bubbles: true,
composed: true,
}))
}));
this.dispatchEvent(new CustomEvent('impost:solved', {
detail: {
challenge: this.challengeData,
solution: this.solution,
},
bubbles: true,
composed: true,
}));
}
}
solvePreventDefault(event: Event) {
@@ -400,12 +360,6 @@ export class PowCaptcha extends LitElement {
this.challengejson = '';
}
if (this.errorMessage) {
return html`
<div class="error-message">${this.errorMessage}</div>
`;
}
if (this.challengeData === null) {
return html`
<div class="loading-message">Loading captcha challenge...</div>
@@ -416,9 +370,9 @@ export class PowCaptcha extends LitElement {
<div class="impost-widget">
<div class="impost-main">
<div class="impost-checkbox">
${!this.isSolving ? html`
<input type="checkbox" id="impost-checkbox-${this.uid}" @click=${this.solvePreventDefault} @change=${this.solvePreventDefault} ?disabled=${this.disabled} ?checked=${this.solved}>
` : html`
${this.status !== 'solving' ? html`${this.status === 'error' ? html`<svg class="impost-error-icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M12 14q-.425 0-.712-.288T11 13V6q0-.425.288-.712T12 5t.713.288T13 6v7q0 .425-.288.713T12 14m0 5q-.425 0-.712-.288T11 18t.288-.712T12 17t.713.288T13 18t-.288.713T12 19"/></svg>` : html`
<input type="checkbox" id="impost-checkbox-${this.uid}" @click=${this.solvePreventDefault} @change=${this.solvePreventDefault} ?disabled=${this.disabled} ?checked=${this.status === 'solved'}>
`}` : html`
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
<g stroke="currentColor">
@@ -431,11 +385,11 @@ export class PowCaptcha extends LitElement {
</svg>
`}
</div>
<label for="impost-checkbox-${this.uid}">${this.solved ? 'Verified' : html`${this.isSolving ? 'Verifying...' : 'I am not a robot'}`}</label>
<label for="impost-checkbox-${this.uid}">${this.status === 'error' ? 'Something went wrong' : this.status === 'solved' ? 'Verified' : html`${this.status === 'solving' ? 'Verifying...' : 'I am not a robot'}`}</label>
</div>
<div class="impost-footer">
${this.isSolving && this.hashRate > 0 ? html`
${this.showHashrate && this.status === 'solving' && this.hashRate > 0 ? html`
<div>
<span>H/s:</span>
<span>${this.hashRate.toFixed(2)}</span>
@@ -449,8 +403,9 @@ export class PowCaptcha extends LitElement {
<input
type = "text"
id = "impost-solution"
class="hidden"
name = "impost-solution"
id = "impost-solution-${this.uid}"
style="display: none;"
.value = ${this.solution}
/>
`;

View File

@@ -1 +1 @@
export * from './pow-captcha';
export * from './captcha';

View File

@@ -1,91 +1,64 @@
// This worker just sits on another thread and waits for message to solve
// challenges so that we dont block the render thread
import {
type WorkerRequest,
type SolutionMessage,
WorkerMessageType,
WorkerResponseType,
} from "./types/worker";
import { ChallengeStrategy } from "@impost/lib";
import { type SolverModule, init_solver, solve_leaading_zeroes_challenge, solve_target_number_challenge } from '@impost/lib/solver';
import { type Challenge } from "@impost/lib";
import { type SolverModule, init_solver, solve_challenge as libimpost_solve_challenge } from '@impost/lib/solver';
import * as Comlink from 'comlink';
let solver: SolverModule | null = null;
let atomic_nonce: Int32Array | null = null;
let atomic_solution: Int32Array | null = null;
onmessage = async (event: MessageEvent<WorkerRequest>) => {
if (event.data.type === WorkerMessageType.Init) {
atomic_nonce = new Int32Array(event.data.sab, 0, 1);
atomic_solution = new Int32Array(event.data.sab, 4, 1);
let module = event.data.module;
/**
* Initializes the worker.
* Must be called before any solve functions.
* @param module The solver web assembly module.
* @param sab The SharedArrayBuffer for nonce and solution.
*/
export async function init(module: WebAssembly.Module, sab: SharedArrayBuffer): Promise<void> {
atomic_nonce = new Int32Array(sab, 0, 1);
atomic_solution = new Int32Array(sab, 4, 1);
try {
solver = await init_solver({
__get_solution: () => Atomics.load(atomic_solution!, 0),
__set_solution: (value: number) => Atomics.store(atomic_solution!, 0, value),
__cmpxchg_solution: (expected: number, replacement: number) => Atomics.compareExchange(atomic_solution!, 0, expected, replacement),
__fetch_add_nonce: (value: number) => Atomics.add(atomic_nonce!, 0, value),
}, module);
} catch (error: any) {
postMessage({
type: WorkerResponseType.Error,
error: `Could not load WASM solver: ${error.message}`,
} as SolutionMessage);
return;
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "Failed to load WASM solver",
} as SolutionMessage);
return;
}
postMessage({
type: WorkerResponseType.InitOk,
} as SolutionMessage);
return;
try {
solver = await init_solver({
__get_solution: () => Atomics.load(atomic_solution!, 0),
__set_solution: (value: number) => Atomics.store(atomic_solution!, 0, value),
__cmpxchg_solution: (expected: number, replacement: number) => Atomics.compareExchange(atomic_solution!, 0, expected, replacement),
__fetch_add_nonce: (value: number) => Atomics.add(atomic_nonce!, 0, value),
__log: (str_ptr: number, str_len: number) => console.log(new TextDecoder().decode(new Uint8Array(solver!.exports.memory.buffer, str_ptr, str_len))),
}, module);
} catch (error: any) {
console.error("Worker: Failed to initialize WASM solver", error);
throw new Error(`Could not load WASM solver in worker: ${error.message}`);
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "WASM solver not loaded",
} as SolutionMessage);
return;
throw new Error("Worker: Failed to load WASM solver.");
}
}
/**
* Solves a given challenge.
* @param challenge The challenge data.
* @returns The nonce solution on success, or throws an error.
*/
export async function solve_challenge(challenge: Challenge): Promise<string> {
if (!solver || !atomic_nonce || !atomic_solution) {
throw new Error("WASM solver or atomics not initialized in worker. Call init() first.");
}
if (atomic_nonce === null || atomic_solution === null) {
throw new Error("Atomics not initialized");
console.log("Worker: Solving challenge...");
const solutionNonce = libimpost_solve_challenge(solver, challenge);
if (solutionNonce < 0) {
throw new Error(`Worker: Failed to solve challenge. Internal code: ${solutionNonce}`);
}
const { strategy } = event.data;
const finalSolution = Atomics.load(atomic_solution, 0);
return finalSolution.toString();
}
let solution: number;
switch (strategy) {
case ChallengeStrategy.LeadingZeroes:
solution = solve_leaading_zeroes_challenge(solver, event.data)
break;
case ChallengeStrategy.TargetNumber:
solution = solve_target_number_challenge(solver, event.data)
break;
}
// we are just assuming that if its less than -1, its the min i32
if (solution < 0) {
return postMessage({
type: WorkerResponseType.Error,
error: "failed to solve challenge",
} as SolutionMessage);
}
postMessage({
type: WorkerResponseType.Solution,
nonce: solution === -1 ? null : solution.toString()
} as SolutionMessage);
};
// * Do not forget to expose functions we want to use on the main thread
Comlink.expose({ solve_challenge, init });

View File

@@ -1,60 +0,0 @@
import { ChallengeStrategy } from "@impost/lib";
export enum WorkerMessageType {
Init = "init",
Challenge = "challenge",
}
interface WorkerInitRequest {
type: WorkerMessageType.Init;
module: WebAssembly.Module;
sab: SharedArrayBuffer;
}
interface ChallengeLeadingZeroesSolveRequest {
strategy: ChallengeStrategy.LeadingZeroes;
salt: string;
difficulty: number;
}
interface WorkerChallengeLeadingZeroesSolveRequest extends ChallengeLeadingZeroesSolveRequest {
type: WorkerMessageType.Challenge;
}
interface ChallengeTargetNumberSolveRequest {
strategy: ChallengeStrategy.TargetNumber;
target: string;
salt: string;
}
interface WorkerChallengeTargetNumberSolveRequest extends ChallengeTargetNumberSolveRequest {
type: WorkerMessageType.Challenge;
}
export type ChallengeSolveRequest = ChallengeLeadingZeroesSolveRequest | ChallengeTargetNumberSolveRequest;
type WorkerChallengeSolveRequest = WorkerChallengeLeadingZeroesSolveRequest | WorkerChallengeTargetNumberSolveRequest;
export type WorkerRequest = WorkerInitRequest | WorkerChallengeSolveRequest;
export enum WorkerResponseType {
Error = "error",
InitOk = "init_ok",
Solution = "solution",
}
interface ErrorMessageResponse {
type: WorkerResponseType.Error;
error: string;
}
interface SolutionMessageResponse {
type: WorkerResponseType.Solution;
nonce: string;
}
interface InitOkMessageResponse {
type: WorkerResponseType.InitOk;
}
export type SolutionMessage = ErrorMessageResponse | SolutionMessageResponse | InitOkMessageResponse;

View File

@@ -1 +1 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />

View File

@@ -17,13 +17,10 @@ export default defineConfig({
dir: 'dist',
},
},
// WARN: this setting has caused issues for me in the past, but now it
// seems to work fine. I'm not sure why, but minifying the bundle saves
// 5KB, and it doesnt *cause* issues, so I'm leaving it on.
minify: true,
minify: true
},
plugins: [
dts(),
dts()
],
});