Initial commit

Once again a weird place to commit, I have already done a lot of work, but I am just bad at using git, okay.
This commit is contained in:
Zoe
2025-11-17 16:12:26 +00:00
commit cfab3d0b8f
58 changed files with 18689 additions and 0 deletions

0
.gitignore vendored Normal file
View File

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"workbench.colorCustomizations": {
"minimap.background": "#00000000",
"scrollbar.shadow": "#00000000"
}
}

10
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"label": "Build",
"command": "just build",
}
],
}

23
LICENSE Normal file
View File

@@ -0,0 +1,23 @@
Boost Software License - Version 1.0 - August 17th, 2003
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

7
NOTES.md Normal file
View File

@@ -0,0 +1,7 @@
# Notes
Ideas:
- plugable solvers: users can implement their own solvers and use them with the
widget. This would allow easily changing the PoW algorithm, or even using
different PoW algorithms for different challenges.

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# Impost
Impost /ˈimˌpōst/ _noun_ a tax or compulsory payment
Impost is a PoW anti-spam solution, or for short, a PoW captcha. Instead of
spying on your users and using heavy captchas, Impost uses PoW to impose a cost
on sending requests. To a single user, this is a negligable few seconds, but at
scale, it can be a significant deterrent to spam.
This is the impost monorepo, containing the following packages:
- `@impost/widget`: A web component that can be used to embed an Impost widget
in your website.
- `@impost/lib`: A library that can be used to generate, solve, and verify
proofs.
It also contains a `solver` package, which is the PoW solver written in Zig,
`@impost/lib` is built on top of, an example of how to use the solver in a
nuxt 3 project.

1
example-app/.example.env Normal file
View File

@@ -0,0 +1 @@
YAPTCHA_HMAC_SECRET=xxx # openssl rand -base64 32

5
example-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.output/
.nuxt/
.env
*.wasm

6
example-app/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"workbench.colorCustomizations": {
"minimap.background": "#00000000",
"scrollbar.shadow": "#00000000"
}
}

50
example-app/README.md Normal file
View File

@@ -0,0 +1,50 @@
# YAPTCHA
Yet Another Pow capTCHA.
## What is this
YAPTCHA is a proof of work based challenge-response system that is designed to
ward off spam and abuse.
<!-- TODO: -->
## Basic configuration notes
Leading zeroes takes in a difficulty from 1 to 64, this indicates the number of
leading hexidecimal zeroes that are required for a problem to be solved. The
probability for a digit to be a zero is 1/16^$D$ where $D$ is the difficulty.
The following chart is provided for a baseline of the theoretical expected
average iterations it would take to solve challenge of difficulty $D$.
| $D$ | expected average iterations |
| --- | --------------------------- |
| 1 | 16 |
| 2 | 256 |
| 3 | 4,096 |
| 4 | 65,536 |
| 5 | 1,048,576 |
| 6 | 16,777,216 |
| 7 | 268,435,456 |
| 8 | 4,294,967,296 |
Target Number takes in a max number of iterations, $M$, to be solved. The
probability that a solution is solved will be 1/targetNumber. This provides a
more preciese way of determining the difficulty of a challenge. With leading
zeroes, there is only a theoretical chance that a solution is found within a
certain number of iterations. With target number, there is a gurantee that a
solution will be found in, at most, $M$ iterations.
## Interesting links and blogs I used while writing this
- [Anubis](https://github.com/TecharoHQ/anubis) for inspiration. (once again, I
have some strong opinions on Anubis, but thats is still neithehrere nor there)
- ["Proof of Mutex: Outspeeding Anubis with Valid PoW" by yumechi](https://yumechi.jp/en/blog/2025/proof-of-mutex-outspeeding-anubis-with-valid-pow/)
- https://www.arkoselabs.com/blog/proof-of-work-invisible-security-visible-results/
for giving me hope that PoW is not a fruitless endeavor for captchas.
- [ALTCHA](https://github.com/altcha-org/altcha) purely to see how another
option in the space works. (I have some choice words about the implementation
of ALTCHA, but I digress. **No code from ALTCHA was used in this project.**)
- [The monero project](https://www.getmonero.org/) for its list of algorithms
they use in xmrig, and some inspiration.

17
example-app/app/app.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<style>
html,
body {
background: #090508;
color-scheme: dark;
color: white;
font-family: sans-serif;
margin: 0;
padding: 0;
}
</style>

View File

@@ -0,0 +1,411 @@
<script setup lang="ts">
import { type WorkerRequest, type SolutionMessage, WorkerResponseType, WorkerMessageType, ChallengeStrategy, type Challenge } from '~/types/pow';
import WASMSolverUrl from "~/utils/solver.wasm?url";
import ChallengeWorker from '~/utils/worker?worker';
let shared_atomics = new SharedArrayBuffer(12);
let workers: Worker[] = [];
let workers_initialized = false;
if (import.meta.client) {
try {
// const num_workers = 1;
const num_workers = navigator.hardwareConcurrency;
for (let i = 0; i < num_workers; i++) {
workers.push(new ChallengeWorker());
}
} catch (error: any) {
console.error("Failed to create worker:", error);
}
}
let autoSolve: Ref<boolean> = ref(false);
watch(autoSolve, async () => {
if (autoSolve.value) {
while (autoSolve.value) {
if (solving.value) {
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
await getChallenge();
await solveChallenge();
if (!autoSolve.value) {
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
});
let total_solved: Ref<number> = ref(0);
let total_solving_for: Ref<number> = ref(0);
function pluralize(value: number, string: string) {
return value === 1 ? string : `${string}s`;
}
let challenge_loading: Ref<boolean> = ref(false);
let challenge_loading_indicator: Ref<string> = ref('');
let challenge: Ref<Challenge | null> = ref(null);
let nonce: Ref<string | null> = ref(null);
let solved: Ref<boolean> = ref(false);
let solving_for: Ref<string> = ref('0');
const number_formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 2,
});
// the hashrate of all the runs
let hashrate_array: Ref<Array<number>> = ref([]);
let hashrate = computed(() => {
if (hashrate_array.value.length === 0) {
return 0;
}
return hashrate_array.value.reduce((a, b) => a + b, 0) / hashrate_array.value.length;
});
let solving = ref(false);
let solveTime: Ref<number> = ref(0);
let solveTimeout: Ref<any | null> = ref(null);
let challenge_error: Ref<string | null> = ref(null);
let difficulty: Ref<number> = ref(0);
let { data } = await useFetch('/api/pow/difficulty');
if (data.value) {
difficulty.value = data.value.difficulty;
}
const MESSAGE_TIMEOUT = 3000;
async function getChallenge() {
challenge_error.value = null;
challenge_loading_indicator.value = '';
challenge.value = null;
nonce.value = null;
challenge_loading.value = true;
const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let spinner_index = 0;
const loading_interval = setInterval(() => {
challenge_loading_indicator.value = spinners[spinner_index]!;
spinner_index = (spinner_index + 1) % spinners.length;
console.log(spinners[spinner_index]);
}, 100);
try {
const new_challenge = await $fetch('/api/pow/challenge');
challenge.value = new_challenge.challenge;
} catch (error: any) {
console.error("Failed to get challenge:", error);
challenge_error.value = `Failed to get challenge: ${error.message}`;
} finally {
challenge_loading.value = false;
clearInterval(loading_interval);
}
}
async function initWorkers() {
if (workers_initialized) {
throw createError("Workers already initialized");
}
workers_initialized = true;
const module = await WebAssembly.compileStreaming(fetch(WASMSolverUrl));
const atomics_view = new Int32Array(shared_atomics);
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, 0);
console.debug(`Initializing ${workers.length} workers`);
let worker_promises: Promise<void>[] = [];
for (let i = 0; i < workers.length; i++) {
const worker = workers[i]!;
worker_promises.push(new Promise<void>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
if (event.data.type === WorkerResponseType.Error) {
console.error("Worker error:", event.data.error);
reject(event.data.error);
}
if (event.data.type === WorkerResponseType.Ok) {
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: module,
sab: shared_atomics,
} as WorkerRequest);
}));
}
const timeoutMs = 10 * 1000;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Function timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
await Promise.race([
Promise.all(worker_promises),
timeoutPromise,
]);
console.log("All workers initialized");
}
async function getChallengeSolution(worker: Worker, request: { strategy: ChallengeStrategy.LeadingZeroes, target: string, difficulty: number } | { strategy: ChallengeStrategy.TargetNumber, target: string, salt: string }): 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,
target: request.target,
difficulty: request.difficulty,
} as WorkerRequest);
break;
case ChallengeStrategy.TargetNumber:
worker.postMessage({
strategy: ChallengeStrategy.TargetNumber,
target: request.target,
salt: request.salt,
} as WorkerRequest);
break;
}
});
}
async function solveChallenge() {
if (!challenge.value?.target) {
return;
}
if (!workers_initialized) {
try {
await initWorkers();
} catch (error: any) {
console.error("Failed to initialize workers:", error);
return;
}
}
const atomics_view = new Int32Array(shared_atomics);
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, -1);
solved.value = false;
challenge_error.value = null;
solving.value = true;
solveTime.value = 0;
solving_for.value = '0';
let startTime = performance.now();
let solving_for_interval = setInterval(() => {
solving_for.value = ((performance.now() - startTime) / 1000).toFixed(1);
}, 100);
function cleanup() {
clearTimeout(solveTimeout.value);
solveTimeout.value = setTimeout(() => {
solveTime.value = 0;
}, MESSAGE_TIMEOUT);
solved.value = false;
solving.value = false;
clearInterval(solving_for_interval);
}
try {
let request: { strategy: ChallengeStrategy.LeadingZeroes, target: string, difficulty: number } | { strategy: ChallengeStrategy.TargetNumber, target: string, salt: string };
switch (challenge.value.strategy) {
case ChallengeStrategy.LeadingZeroes:
request = {
strategy: ChallengeStrategy.LeadingZeroes,
target: challenge.value.target,
difficulty: challenge.value.difficulty,
};
break;
case ChallengeStrategy.TargetNumber:
request = {
strategy: ChallengeStrategy.TargetNumber,
target: challenge.value.target,
salt: challenge.value.salt,
};
break;
}
let worker_promises: Promise<SolutionMessage>[] = [];
for (let worker of workers) {
// dispatch to all workers, func is async so it will not block
worker_promises.push(getChallengeSolution(worker, request));
}
let solution = await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
throw createError(solution.error);
}
if (solution.type === WorkerResponseType.Ok) {
throw createError("spurious solution");
}
console.log(shared_atomics.slice(8, 12));
nonce.value = Atomics.load(atomics_view, 1).toString();
solveTime.value = Math.floor(performance.now() - startTime);
total_solved.value += 1;
total_solving_for.value += solveTime.value;
// since nonce is the number of iterations we have completed, we can divide that by how long it took in second
// to get H/s
hashrate_array.value.push(+Atomics.load(atomics_view, 1) / (solveTime.value / 1000));
clearTimeout(solveTimeout.value);
await $fetch('/api/pow/challenge', {
method: 'POST',
body: {
challenge: challenge.value.target,
nonce: nonce.value,
}
});
solved.value = true;
solveTimeout.value = setTimeout(() => {
solveTime.value = 0;
solved.value = false;
}, MESSAGE_TIMEOUT);
switch (challenge.value.strategy) {
case ChallengeStrategy.LeadingZeroes:
console.debug("Solved challenge with difficulty", challenge.value.difficulty, "in " + solveTime.value + "ms");
break;
case ChallengeStrategy.TargetNumber:
console.debug("Solved challenge with salt", challenge.value.salt, "in " + solveTime.value + "ms");
break;
}
solving.value = false;
clearInterval(solving_for_interval);
} catch (error: any) {
challenge_error.value = `Failed to solve challenge: ${error.message}`;
console.error(error);
cleanup();
}
}
async function setDifficulty(difficulty: number) {
const response = await $fetch('/api/pow/difficulty', {
method: 'PUT',
body: {
difficulty,
}
});
console.log(response);
}
</script>
<template>
<div class="flex justify-between" v-if="hashrate_array.length !== 0">
<span>Your average Hashrate: {{ number_formatter.format(hashrate) }} H/s</span>
<span>You have solved {{ total_solved }} {{ pluralize(total_solved, "challenge") }} in
{{ number_formatter.format(total_solving_for / 1000) }}s</span>
<span>Your Hashrate on the last challenge: {{ number_formatter.format(hashrate_array.at(-1)!) }} H/s</span>
</div>
<p v-else>You have not solved any challenges yet</p>
<p>Challenge: <span v-if="challenge_loading">{{ challenge_loading_indicator }}</span>
<span v-else>{{ challenge }}</span>
</p>
<p>Nonce: {{ nonce }}</p>
<button @click="getChallenge()" :disabled="solving || challenge_loading">
Get Challenge
</button>
<button @click="solveChallenge()" :disabled="solving || challenge === null || nonce !== null">
<span v-if="!solving">Solve Challenge</span>
<span v-else>Solving challenge for {{ solving_for }}s...</span>
</button>
<div>
<div v-if="solveTime && !challenge_error" class="min-h-[1rem]">
<span v-if="solved">Challenge solved in {{ solveTime }}ms!</span>
<span v-else>Validating solution...</span>
</div>
<div v-else-if="challenge_error">{{ challenge_error }}</div>
<p v-else class="empty-p"><!-- Empty so there is no content shift when there is text or isnt --></p>
</div>
<div class="flex flex-row w-fit">
<label for="autoSolve">Auto solve</label>
<input class="w-min" type="checkbox" v-model="autoSolve" id="autoSolve"></input>
</div>
<div class="flex flex-col w-fit">
<label for="difficulty">Difficulty</label>
<input class="w-min" type="number" min="1" max="64" v-model="difficulty" @change="setDifficulty(difficulty)"
id="difficulty"></input>
</div>
</template>
<style scoped>
button {
min-width: 140px;
padding: 0.25rem 0.375rem;
}
.empty-p {
margin: 0;
&::after {
content: "-";
visibility: hidden;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const { data: challengeData } = await useFetch('/api/pow/challenge');
if (!challengeData) {
throw createError({
statusCode: 500,
message: 'Failed to fetch challenge',
});
}
function solved(ev: CustomEvent) {
console.log("Solved:", ev.detail.solution);
}
function formsubmit(ev: Event) {
console.log("Submitted form");
}
onMounted(() => {
document.addEventListener('impost:solved', (ev) => solved(ev as CustomEvent));
});
onUnmounted(() => {
document.removeEventListener('impost:solved', (ev) => solved(ev as CustomEvent));
});
</script>
<template>
<div class="flex justify-center items-center h-screen w-screen ">
<form @submit.prevent="formsubmit"
class="p-5 rounded-2xl bg-dark-9 border-coolGray-600 border flex flex-col gap-2">
<pow-captcha challengeUrl="/api/pow" auto="onsubmit"
:challengejson="JSON.stringify(challengeData!.challenge)" @impost:solved="solved" />
<input class="bg-blue-7 text-white font-semibold px-4 py-2.5 border-0 rounded-md" type="submit"
value="Submit" />
</form>
</div>
</template>

View File

@@ -0,0 +1,3 @@
import '@impost/widget';
export default defineNuxtPlugin((nuxtApp) => { });

View File

@@ -0,0 +1,6 @@
const adjectives = ['swift', 'silent', 'hidden', 'clever', 'brave', 'sharp', 'shadow', 'crimson', 'bright', 'quiet', 'loud', 'happy', 'dark', 'evil', 'good', 'intelligent', 'lovely', 'mysterious', 'peaceful', 'powerful', 'pure', 'quiet', 'shiny', 'sleepy', 'strong', 'sweet', 'tall', 'warm', 'gentle', 'kind', 'nice', 'polite', 'rough', 'rude', 'scary', 'shy', 'silly', 'smart', 'strange', 'tough', 'ugly', 'vivid', 'wicked', 'wise', 'young', 'sleepy'];
const nouns = ['fox', 'river', 'stone', 'cipher', 'link', 'comet', 'falcon', 'signal', 'anchor', 'spark', 'stone', 'comet', 'rocket', 'snake', 'snail', 'shark', 'elephant', 'cat', 'dog', 'whale', 'orca', 'cactus', 'flower', 'frog', 'toad', 'apple', 'strawberry', 'raspberry', 'lemon', 'bot', 'gopher', 'dinosaur', 'racoon', 'penguin', 'chameleon', 'atom', 'particle', 'witch', 'wizard', 'warlock', 'deer']
export function getWorkerName() {
return `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}`;
}

View File

@@ -0,0 +1,170 @@
// 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,
ChallengeStrategy,
} from "~/types/pow";
const worker_name = getWorkerName();
let solver: SolverModule | null = null;
let atomic_nonce: Int32Array | null = null;
let atomic_solution: Int32Array | null = null;
async function loadWasmSolver(module: WebAssembly.Module) {
if (atomic_nonce === null || atomic_solution === null) {
throw createError("Atomics not initialized");
}
console.debug(`[${worker_name}]: Loading WASM solver`);
solver = await WebAssembly.instantiate(module, {
env: {
__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: (ptr: number, len: number) => {
const string_data = new Uint8Array(solver!.exports.memory.buffer, ptr, len);
console.log(`[${worker_name}]: ${new TextDecoder().decode(string_data)}`);
},
}
}) as unknown as SolverModule;
console.debug(`[${worker_name}]: WASM solver loaded`);
}
onmessage = async (event: MessageEvent<WorkerRequest>) => {
if (event.data.type === WorkerMessageType.Init) {
console.log(`[${worker_name}]: Initializing...`);
atomic_nonce = new Int32Array(event.data.sab, 0, 1);
atomic_solution = new Int32Array(event.data.sab, 4, 1);
try {
await loadWasmSolver(event.data.module);
} catch (error: any) {
console.error(`[${worker_name}]: Failed to load WASM solver:`, error);
postMessage({
type: WorkerResponseType.Error,
error: `Could not load WASM solver: ${error.message}`,
} as SolutionMessage);
return;
}
if (!solver) {
console.error(`[${worker_name}]: Failed to load WASM solver`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to load WASM solver",
} as SolutionMessage);
return;
}
postMessage({
type: WorkerResponseType.Ok,
} as SolutionMessage);
return;
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "WASM solver not loaded",
} as SolutionMessage);
return;
}
const { strategy } = event.data;
const encoder = new TextEncoder();
let solution: number;
let target: string = event.data.target;
let target_bytes, target_ptr;
let memory;
switch (strategy) {
case ChallengeStrategy.LeadingZeroes:
const { difficulty } = event.data;
console.debug(`[${worker_name}]: recieved ${strategy} challenge: ${target}, difficulty: ${difficulty}`);
target_bytes = encoder.encode(target);
target_ptr = solver.exports.malloc(target_bytes.length);
if (target_ptr === 0 || target_ptr === null) {
console.error(`[${worker_name}]: Failed to allocate memory for challenge string`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to allocate memory for challenge string",
} as SolutionMessage);
return;
}
memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(target_bytes, target_ptr);
solution = solver.exports.solve_leaading_zeroes_challenge(
target_ptr,
target.length,
difficulty,
);
console.debug(`[${worker_name}]: WASM solver found nonce: ${solution}`);
break;
case ChallengeStrategy.TargetNumber:
const { salt } = event.data;
console.debug(`[${worker_name}]: recieved ${strategy} challenge: ${target}, salt: ${salt}`);
const salt_bytes = encoder.encode(salt);
target_bytes = encoder.encode(target);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
console.error(`[${worker_name}]: Failed to allocate memory for salt string`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to allocate memory for salt string",
} as SolutionMessage);
return;
}
target_ptr = solver.exports.malloc(target_bytes.length);
if (target_ptr === 0 || target_ptr === null) {
console.error(`[${worker_name}]: Failed to allocate memory for target string`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to allocate memory for target string",
} as SolutionMessage);
return;
}
memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(target_bytes, target_ptr);
solution = solver.exports.solve_target_number_challenge(
target_ptr,
target_bytes.length,
salt_ptr,
salt_bytes.length,
);
console.debug(`[${worker_name}]: WASM solver found nonce: ${solution}`);
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);
};

2283
example-app/bun.lock Normal file

File diff suppressed because it is too large Load Diff

7
example-app/config.toml Normal file
View File

@@ -0,0 +1,7 @@
strategy = "target_number"
[leading_zeroes]
difficulty = 4
[target_number]
max_number = 10000

View File

@@ -0,0 +1,57 @@
import { defineNuxtConfig } from "nuxt/config";
export default defineNuxtConfig({
ssr: true,
devServer: {
port: 3000,
host: '0.0.0.0'
},
vite: {
server: {
allowedHosts: true,
headers: {
'strict-transport-security': 'max-age=63072000; includeSubDomains; preload',
'x-frame-options': 'DENY',
'x-xss-protection': '1; mode=block',
'cross-origin-opener-policy': 'same-origin',
'cross-origin-embedder-policy': 'require-corp',
'access-control-allow-origin': '*',
}
},
},
modules: ['@unocss/nuxt', ['nuxt-ssr-lit', { litElementPrefix: 'pow-' }]],
nitro: {
moduleSideEffects: ["@impost/widget"],
experimental: {
wasm: true,
},
},
imports: {
transform: {
// only necessary in the monorepo since vite is going out to the
// source of the widget and transforming it and clobbering it.
exclude: [/impost/],
}
},
vue: {
compilerOptions: {
isCustomElement: (tag) => tag.includes('pow-'),
},
},
// typescript: {
// tsConfig: {
// compilerOptions: {
// paths: {
// "~/*": ["./app/*"],
// "~~/*": ["./*"],
// }
// }
// }
// }
compatibilityDate: '2025-11-05',
});

8722
example-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
example-app/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "hello-nuxt",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"dependencies": {
"@lit/reactive-element": "^2.1.1",
"js-toml": "^1.0.2",
"lit-element": "^4.2.1",
"lit-html": "^3.3.1",
"nuxt": "latest",
"nuxt-ssr-lit": "1.6.32",
"zod": "^4.1.12",
"@impost/lib": "file:../packages/lib",
"@impost/widget": "file:../packages/widget"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@unocss/nuxt": "^66.5.4",
"unocss": "^66.5.4"
}
}

View File

@@ -0,0 +1,44 @@
import { defineEventHandler } from 'h3'
import { config } from '~~/server/utils/config';
import { generate_challenge } from '@impost/lib/validator';
import { ChallengeStrategy } from '@impost/lib';
import { CHALLENGE_TIMEOUT_MS, outstandingChallenges } from '~~/server/utils/pow';
export default defineEventHandler(async () => {
let challenge_config;
switch (config.strategy) {
case ChallengeStrategy.LeadingZeroes:
challenge_config = {
parameters: { expires_at: CHALLENGE_TIMEOUT_MS },
strategy: config.strategy,
difficulty: config.leading_zeroes?.difficulty!,
};
break;
case ChallengeStrategy.TargetNumber:
challenge_config = {
parameters: { expires_at: CHALLENGE_TIMEOUT_MS },
strategy: config.strategy,
max_number: config.target_number.max_number,
};
break;
}
let challenge = await generate_challenge(challenge_config);
if (challenge === null) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to generate challenge'
});
}
outstandingChallenges.set(challenge.salt, {
challenge, timeout: setTimeout(() => {
console.log("Challenge timed out:", challenge.salt);
outstandingChallenges.delete(challenge.salt);
}, CHALLENGE_TIMEOUT_MS)
});
return {
challenge,
}
})

View File

@@ -0,0 +1,45 @@
import { defineEventHandler } from 'h3'
import { validate_challenge } from '@impost/lib/validator';
import * as z from 'zod';
import { outstandingChallenges } from '~~/server/utils/pow';
const challengeSchema = z.object({
challenge: z.string(),
nonce: z.string()
})
// post handler that takes in the challenge, and the nonce
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, challengeSchema.safeParse);
if (!body.success) {
throw createError({
statusCode: 400,
statusMessage: 'Validation failed'
})
}
let target = body.data.challenge;
let nonce = body.data.nonce;
// check if the challenge is valid
let challenge_valid = await validate_challenge(outstandingChallenges.get(target)!.challenge, {
challenge: target,
nonce: nonce
});
if (challenge_valid) {
// clear the challenge
clearTimeout(outstandingChallenges.get(target)!.timeout);
outstandingChallenges.delete(target);
return {
message: 'Challenge solved'
};
}
throw createError({
statusCode: 400,
statusMessage: 'Challenge is not valid'
})
})

View File

@@ -0,0 +1,19 @@
import { defineEventHandler } from 'h3'
import { ChallengeStrategy } from '@impost/lib';
export default defineEventHandler((event) => {
let difficulty: number;
switch (config.strategy) {
case ChallengeStrategy.LeadingZeroes:
difficulty = config.leading_zeroes.difficulty!;
break;
case ChallengeStrategy.TargetNumber:
difficulty = config.target_number.max_number!;
break;
}
return {
difficulty
}
})

View File

@@ -0,0 +1,35 @@
import { defineEventHandler } from 'h3'
import { ChallengeStrategy } from '@impost/lib';
export default defineEventHandler(async (event) => {
const body = await readBody(event)
let difficulty = body.difficulty;
switch (config.strategy) {
case ChallengeStrategy.LeadingZeroes:
if (!difficulty || difficulty < 1 || difficulty > 64) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request',
});
}
config.leading_zeroes.difficulty = difficulty;
break;
case ChallengeStrategy.TargetNumber:
if (!difficulty || difficulty < 1 || difficulty > 100_000_000) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request',
});
}
config.target_number.max_number = difficulty;
break;
}
return {
message: 'Challenge difficulty set'
};
});

View File

@@ -0,0 +1,10 @@
import { defineEventHandler, setHeader } from 'h3'; // H3 is the underlying HTTP framework in Nuxt 3
export default defineEventHandler((event) => {
setHeader(event, 'Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
setHeader(event, 'X-Frame-Options', 'DENY');
setHeader(event, 'X-XSS-Protection', '1; mode=block');
setHeader(event, 'Cross-Origin-Opener-Policy', 'same-origin');
setHeader(event, 'Cross-Origin-Embedder-Policy', 'require-corp');
setHeader(event, 'access-control-allow-origin', '*');
});

View File

@@ -0,0 +1,42 @@
import { readFileSync } from 'fs';
import { load } from 'js-toml';
import z from 'zod';
import { ChallengeStrategy } from "@impost/lib";
const LeadingZeroesSchema = z.object({
strategy: z.literal(ChallengeStrategy.LeadingZeroes),
leading_zeroes: z.object({
difficulty: z.number().int().min(1).max(64),
}),
});
const TargetNumberSchema = z.object({
strategy: z.literal(ChallengeStrategy.TargetNumber),
target_number: z.object({
max_number: z.number().int().min(1).max(100_000),
}),
});
export type Config = z.infer<typeof Config>;
export const Config = z.discriminatedUnion('strategy', [
LeadingZeroesSchema,
TargetNumberSchema,
]);
export let config: Config;
try {
config = Config.parse(load(readFileSync('./config.toml', 'utf-8')));
} catch (error: any) {
if (error instanceof z.ZodError) {
console.error("Failed to parse config:");
for (const issue of error.issues) {
console.error(issue.message, issue.path);
}
} else {
console.error("Failed to parse config:", error);
}
process.exit(1);
}

View File

@@ -0,0 +1,6 @@
import { Challenge } from "@impost/lib";
export const outstandingChallenges = new Map<string, { challenge: Challenge, timeout: NodeJS.Timeout }>();
// 1 hour
export const CHALLENGE_TIMEOUT_MS = 3600000;

17
example-app/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

11
example-app/uno.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig, presetMini, presetIcons, transformerDirectives } from 'unocss'
export default defineConfig({
presets: [
presetMini(),
presetIcons(),
],
transformers: [
transformerDirectives(),
]
})

26
justfile Normal file
View File

@@ -0,0 +1,26 @@
build: build-widget
wasm-opt-args := "--strip-debug --strip-dwarf --enable-tail-call --enable-bulk-memory -Oz"
zig-build-args := "--release=fast"
npm-runner := "npm"
[working-directory: "solver"]
build-wasm:
zig build {{zig-build-args}}
wasm-opt {{wasm-opt-args}} --enable-simd -o zig-out/bin/solver.wasm zig-out/bin/solver.wasm
# The server does not support simd, so we disable it here
wasm-opt {{wasm-opt-args}} --disable-simd -o zig-out/bin/validator.wasm zig-out/bin/validator.wasm
[working-directory: "packages/lib"]
build-lib: build-wasm
{{npm-runner}} install
{{npm-runner}} link
{{npm-runner}} run build
[working-directory: "packages/widget"]
build-widget: build-lib
{{npm-runner}} install
{{npm-runner}} link @impost/lib
{{npm-runner}} link
{{npm-runner}} run build

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

@@ -0,0 +1,2 @@
dist
node_modules

60
packages/lib/README.md Normal file
View File

@@ -0,0 +1,60 @@
# @impost/lib
This package contains the types and WASM code for the impost. It is useful for
building other packages that want to use the WASM code, a client, or a server.
## Usage
```ts
import {
validate_challenge,
generate_target_number_challenge,
} from "@impost/lib/validator";
import {
init_solver,
get_wasm_module,
solve_target_number_challenge,
} from "@impost/lib/solver";
import { type Challenge, ChallengeStrategy } from "@impost/lib";
const challenge = await generate_target_number_challenge(
// timeout in ms
{
// impost automatically recognizes this parameter, its also recommended to
// add a timestamp to prevent replay or pre-computation attacks
expires_at: Date.now() + 3600,
}
// max number
1_000
);
if (challenge === null) {
throw new Error("Failed to generate challenge");
}
let 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),
},
await get_wasm_module()
);
const solution = await solve_target_number_challenge(solver, {
salt: challenge.salt,
target: challenge.target,
});
const is_valid = await validate_challenge(challenge, {
challenge: challenge.target,
nonce: solution,
});
if (is_valid) {
console.log("Challenge solved!", solution);
}
```

2498
packages/lib/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
packages/lib/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@impost/lib",
"version": "0.1.0",
"type": "module",
"license": "BSL-1.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./solver": {
"types": "./dist/solver.d.ts",
"import": "./dist/solver.js"
},
"./validator": {
"types": "./dist/validator.d.ts",
"import": "./dist/validator.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"watch": "tsc && vite build --watch",
"dev": "vite",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"oxc-minify": "^0.97.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
"uuidv7": "^1.0.2"
}
}

28
packages/lib/src/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export enum ChallengeAlgorithm {
Argon2id = "argon2id",
}
export enum ChallengeStrategy {
LeadingZeroes = "leading_zeroes",
TargetNumber = "target_number",
}
// In this case, the client will repeatedly hash a number with has until it
// finds a hash thaat starts with *difficulty* leading zeroes
export interface ChallengeLeadingZeroes {
algorithm: ChallengeAlgorithm;
strategy: ChallengeStrategy.LeadingZeroes;
salt: string; // random string
difficulty: number;
}
// In this case, the server generates a random number, and the client will hash
// the salt (a random string) + a random number until it finds a hash that is equal to challenge
export interface ChallengeTargetNumber {
algorithm: ChallengeAlgorithm;
strategy: ChallengeStrategy.TargetNumber;
salt: string; // random string
target: string; // hash of salt + random number
}
export type Challenge = ChallengeLeadingZeroes | ChallengeTargetNumber;

View File

@@ -0,0 +1,92 @@
import WASMSolverUrl from '../../../solver/zig-out/bin/solver.wasm?url&inline';
type WasmExports = Record<string, Function> & {
"malloc": (byte_count: number) => number | null;
"free": (ptr: number | null, byte_count: number) => void;
"solve_leaading_zeroes_challenge": (challenge_ptr: number, challenge_len: number, difficulty: number) => number;
"solve_target_number_challenge": (challenge_ptr: number, challenge_len: number, target_ptr: number, target_len: number) => number;
"memory": WebAssembly.Memory;
}
export interface SolverModule extends WebAssembly.Instance {
exports: WasmExports;
}
export type SolverEnv = {
__get_solution: () => number;
__set_solution: (value: number) => void;
__cmpxchg_solution: (expected: number, replacement: number) => number;
__fetch_add_nonce: (value: number) => number;
};
export async function get_wasm_module(): Promise<WebAssembly.Module> {
return WebAssembly.compileStreaming(fetch(WASMSolverUrl));;
}
export async function init_solver(env: SolverEnv, module: WebAssembly.Module): Promise<SolverModule> {
return await WebAssembly.instantiate(module, {
env,
}) as unknown as SolverModule;
}
export function solve_leaading_zeroes_challenge(solver: SolverModule, challenge: { salt: string, difficulty: number }): number {
const { salt, difficulty } = challenge;
const encoder = new TextEncoder();
const salt_bytes = encoder.encode(salt);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
throw new Error("Failed to allocate memory for challenge string");
}
const memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
const ret = solver.exports.solve_leaading_zeroes_challenge(
salt_ptr,
salt_bytes.length,
difficulty,
);
if (ret < 0) {
throw new Error("Failed to solve challenge");
}
return ret;
}
export function solve_target_number_challenge(solver: SolverModule, challenge: { salt: string, target: string }): number {
const { salt, target } = challenge;
const encoder = new TextEncoder();
const salt_bytes = encoder.encode(salt);
const target_bytes = encoder.encode(target);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
throw new Error("Failed to allocate memory for salt string");
}
const target_ptr = solver.exports.malloc(target_bytes.length);
if (target_ptr === 0 || target_ptr === null) {
throw new Error("Failed to allocate memory for target string");
}
const memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(target_bytes, target_ptr);
const ret = solver.exports.solve_target_number_challenge(
target_ptr,
target_bytes.length,
salt_ptr,
salt_bytes.length,
);
if (ret < 0) {
throw new Error("Failed to solve challenge");
}
return ret;
}

View File

@@ -0,0 +1,172 @@
import { ChallengeAlgorithm, ChallengeStrategy, type Challenge } from '.';
import WASMValidatorUrl from '../../../solver/zig-out/bin/validator.wasm?url&inline';
type WasmExports = Record<string, Function> & {
"malloc": (byte_count: number) => number | null;
"free": (ptr: number | null, byte_count: number) => void;
"validate_leading_zeroes_challenge": (challenge_ptr: number, challenge_len: number, nonce_ptr: number, nonce_len: number, difficulty: number) => number;
"validate_target_number_challenge": (target_ptr: number, target_len: number, nonce_ptr: number, nonce_len: number, salt_ptr: number, salt_len: number) => number;
"hash": (challenge_ptr: number, challenge_len: number, nonce_ptr: number, nonce_len: number) => bigint;
"memory": WebAssembly.Memory;
}
export interface ValidatorModule extends WebAssembly.Instance {
exports: WasmExports;
}
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> {
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 = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.LeadingZeroes,
salt,
difficulty,
};
return challenge;
}
async function generate_target_number_challenge(parameters: Object, max_number: number): Promise<Challenge | 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;
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 random_number = new DataView(crypto.getRandomValues(new Uint8Array(4)).buffer).getUint32(0, true) % max_number;
const encoder = new TextEncoder();
const salt_bytes = encoder.encode(salt);
const random_number_bytes = encoder.encode(random_number.toString());
const salt_ptr = validator.exports.malloc(salt_bytes.length);
const random_number_ptr = validator.exports.malloc(random_number_bytes.length);
if (salt_ptr === 0 || salt_ptr === null || random_number_ptr === 0 || random_number_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return null;
}
const memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(random_number_bytes, random_number_ptr);
let target_blob: bigint = validator.exports.hash(salt_ptr, salt_bytes.length, random_number_ptr, random_number_bytes.length);
let target_ptr = Number(target_blob & BigInt(0xFFFFFFFF));
let target_len = Number(target_blob >> BigInt(32));
validator.exports.free(salt_ptr, salt_bytes.length);
validator.exports.free(random_number_ptr, random_number_bytes.length);
// do NOT use `memory` here, by this time it has almost definitely been resized and will cause errors to touch
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 = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.TargetNumber,
salt,
target
};
return challenge;
}
export interface LeadingZeroesChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.LeadingZeroes;
difficulty: number;
}
export interface TargetNumberChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.TargetNumber;
max_number: number;
}
export type ChallengeConfig = LeadingZeroesChallengeConfig | TargetNumberChallengeConfig;
export async function generate_challenge(config: ChallengeConfig): Promise<Challenge | null> {
let challenge: Challenge | null = null;
switch (config.strategy) {
case ChallengeStrategy.LeadingZeroes:
challenge = await generate_leading_zeroes_challenge(config.parameters, config.difficulty);
break;
case ChallengeStrategy.TargetNumber:
challenge = await generate_target_number_challenge(config.parameters, config.max_number);
break;
}
if (challenge === null) {
return null;
}
return challenge;
}
export async function validate_challenge(challenge: Challenge, challenge_solution: { challenge: string, nonce: string }): Promise<boolean> {
const validator = (await WebAssembly.instantiateStreaming(fetch(WASMValidatorUrl))).instance as unknown as ValidatorModule
const encoder = new TextEncoder();
let err;
let memory;
let nonce_bytes, nonce_ptr;
let target_bytes, target_ptr;
switch (challenge.strategy) {
case ChallengeStrategy.LeadingZeroes:
target_bytes = encoder.encode(challenge_solution.challenge);
nonce_bytes = encoder.encode(challenge_solution.nonce);
target_ptr = validator.exports.malloc(challenge_solution.challenge.length);
nonce_ptr = validator.exports.malloc(challenge_solution.nonce.length);
if (target_ptr === 0 || target_ptr === null || nonce_ptr === 0 || nonce_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return false;
}
memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(target_bytes, target_ptr);
memory.set(nonce_bytes, nonce_ptr);
err = validator.exports.validate_leading_zeroes_challenge(target_ptr, target_bytes.length, nonce_ptr, nonce_bytes.length, challenge.difficulty);
validator.exports.free(target_ptr, target_bytes.length);
validator.exports.free(nonce_ptr, nonce_bytes.length);
break;
case ChallengeStrategy.TargetNumber:
target_bytes = encoder.encode(challenge.target);
const salt_bytes = encoder.encode(challenge.salt);
nonce_bytes = encoder.encode(challenge_solution.nonce);
const salt_ptr = validator.exports.malloc(salt_bytes.length);
target_ptr = validator.exports.malloc(target_bytes.length);
nonce_ptr = validator.exports.malloc(nonce_bytes.length);
if (salt_ptr === 0 || salt_ptr === null || target_ptr === 0 || target_ptr === null || nonce_ptr === 0 || nonce_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return false;
}
memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(target_bytes, target_ptr);
memory.set(nonce_bytes, nonce_ptr);
err = validator.exports.validate_target_number_challenge(target_ptr, target_bytes.length, nonce_ptr, nonce_bytes.length, salt_ptr, salt_bytes.length);
validator.exports.free(salt_ptr, salt_bytes.length);
validator.exports.free(target_ptr, target_bytes.length);
validator.exports.free(nonce_ptr, nonce_bytes.length);
break;
}
return err === 0;
}

1
packages/lib/src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"declaration": true,
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,29 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
build: {
target: 'es2022',
rollupOptions: {
input: [
resolve(__dirname, 'src/index.ts'),
resolve(__dirname, 'src/solver.ts'),
resolve(__dirname, 'src/validator.ts'),
],
preserveEntrySignatures: "strict",
output: {
dir: 'dist',
entryFileNames: '[name].js',
preserveModules: false
}
},
sourcemap: true
},
resolve: {
alias: {
'@': '/src'
}
},
plugins: [dts()]
});

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

@@ -0,0 +1,2 @@
dist
node_modules

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-project</title>
<script type="module" src="/src/pow-captcha.ts"></script>
</head>
<body>
<pow-captcha />
</body>
</html>

2576
packages/widget/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "@impost/widget",
"version": "0.1.0",
"type": "module",
"license": "BSL-1.0",
"module": "./dist/impost.js",
"exports": {
".": {
"import": "./dist/impost.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"watch": "tsc && vite build --watch",
"dev": "vite",
"test": "echo \"Error: no test specified\" && exit 1"
},
"peerDependencies": {
"lit": "^3.1.2",
"lit-element": "^3.1.2"
},
"devDependencies": {
"@types/node": "^20.11.24",
"lit": "^3.1.2",
"lit-element": "^3.1.2",
"oxc-minify": "^0.97.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-dts": "^4.5.4"
}
}

View File

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

View File

@@ -0,0 +1,458 @@
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 { get_wasm_module } from '@impost/lib/solver';
import ChallengeWorker from './solver-worker?worker&inline';
@customElement('pow-captcha')
export class PowCaptcha extends LitElement {
static override styles = css`
:host {
display: block;
width: var(--impost-widget-width, 250px);
}
.impost-widget {
display: flex;
flex-direction: column;
border: 1px solid var(--impost-widget-border-color, #505050);
font-family: sans-serif;
background-color: var(--impost-widget-background-color, #070408);
border-radius: var(--impost-widget-border-radius, 8px);
}
.impost-main {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.75rem;
}
.impost-main span {
height: min-content;
}
.impost-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--impost-widget-checkbox-color, #6e6e6eff);
}
.impost-checkbox input {
height: 80%;
width: 80%;
margin: 0;
}
.impost-footer {
display: flex;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: var(--impost-widget-footer-color, #a2a2a2);
justify-content: space-around;
align-items: end;
}
.impost-footer div {
height: min-content;
}
.impost-footer #provider-link {
margin-left: auto;
}
.impost-footer div a {
color: var(--impost-widget-footer-color, #a2a2a2);
}
.impost-footer div a:hover {
text-decoration: none;
}
.hidden {
display: none;
}
`;
// one of: "load", "focus", "submit", "off"
@property({ type: String })
auto: "onload" | "onfocus" | "onsubmit" | "off" = "off";
@property({ type: String })
challengeUrl: string = 'http://localhost:3000/api/pow';
@property({ type: String })
challengejson: string = '';
// 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();
@state()
private solution: string = '';
@state()
private errorMessage: string = '';
@state()
private challengeData: Challenge | null = null;
@state()
private solved: boolean = false;
@state()
private isSolving: boolean = false;
@state()
private disabled: boolean = true;
@state()
private hashRate: number = 0;
// stores the nonce and solution atomics
private sab: SharedArrayBuffer = new SharedArrayBuffer(8);
private solverWorkers: Worker[] | null = null;
private solveStartTime: number | null = null;
private hashRateInterval: number | null = null;
override connectedCallback() {
super.connectedCallback();
this.fetchChallenge();
this.initWorkers();
switch (this.auto) {
case 'onload':
this.solveChallenge();
break;
case 'onfocus':
this.addEventListener('focus', () => this.solveChallenge());
break;
case 'onsubmit':
if (this.parentElement?.nodeName === 'FORM') {
this.parentElement.addEventListener('submit', () => this.solveChallenge());
}
break;
}
}
protected override firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this.disabled = false;
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
for (const worker of this.solverWorkers || []) {
worker.terminate();
this.solverWorkers = null;
}
}
getCurrentWorkingNonce() {
return Atomics.load(new Uint32Array(this.sab), 0);
}
async fetchChallenge() {
this.errorMessage = '';
if (this.challengeData !== null) {
return;
}
if (this.challengejson !== '' && this.challengeData === null) {
this.challengeData = JSON.parse(this.challengejson);
this.challengejson = '';
return;
}
if (isServer) {
return;
}
await fetch(`${this.challengeUrl}/challenge`)
.then(response => response.json())
.then(data => {
this.challengeData = data;
})
.catch(error => {
console.error('Error fetching challenge:', error);
this.errorMessage = 'Failed to fetch challenge. Please try again.';
});
}
async initWorkers() {
this.solverWorkers = [];
const num_workers = navigator.hardwareConcurrency;
for (let i = 0; i < num_workers; i++) {
this.solverWorkers.push(new ChallengeWorker());
}
const atomics_view = new Int32Array(this.sab);
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, 0);
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 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`));
}, timeoutMs);
});
await Promise.race([
Promise.allSettled(worker_promises).then(() => {
console.log('All workers initialized');
}),
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;
}
});
}
async solveChallenge() {
if (!this.challengeData || this.solverWorkers === null) {
this.errorMessage = 'Captcha is not ready. Please wait or refresh.';
return;
}
if (this.solution !== '') {
return;
}
this.solveStartTime = performance.now();
this.hashRateInterval = setInterval(async () => {
const nonce = this.getCurrentWorkingNonce();
this.hashRate = (nonce / ((performance.now() - this.solveStartTime!) / 1000));
}, 250);
this.dispatchEvent(new CustomEvent('impost:solve', {
detail: {
solution: this.solution,
},
bubbles: true,
composed: true,
}))
console.log(this.challengeData);
this.isSolving = true;
this.errorMessage = '';
this.solution = '';
const atomics_view = new Int32Array(this.sab);
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
// unblock, they return 0 since the challenge is already solved.
//
// 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 solution = await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
this.errorMessage = solution.error;
return;
}
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: {
solution: this.solution,
},
bubbles: true,
composed: true,
}))
}
solvePreventDefault(event: Event) {
event.preventDefault();
this.solveChallenge();
}
override render() {
if (this.challengejson !== '' && this.challengeData === null) {
this.challengeData = JSON.parse(this.challengejson);
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>
`;
}
return html`
<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`
<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">
<circle cx="12" cy="12" r="9.5" fill="none" stroke-linecap="round" stroke-width="3">
<animate attributeName="stroke-dasharray" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0 150;42 150;42 150;42 150"/>
<animate attributeName="stroke-dashoffset" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0;-16;-59;-59"/>
</circle>
<animateTransform attributeName="transform" dur="2s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
</g>
</svg>
`}
</div>
<label for="impost-checkbox-${this.uid}">${this.solved ? 'Verified' : html`${this.isSolving ? 'Verifying...' : 'I am not a robot'}`}</label>
</div>
<div class="impost-footer">
${this.isSolving && this.hashRate > 0 ? html`
<div>
<span>H/s:</span>
<span>${this.hashRate.toFixed(2)}</span>
</div>
` : ``}
<div id="provider-link">
Protected by <a href="https://github.com/impost/pow-captcha" target="_blank">Impost</a>
</div>
</div>
</div>
<input
type = "text"
id = "impost-solution"
class="hidden"
.value = ${this.solution}
/>
`;
}
}

View File

@@ -0,0 +1,91 @@
// 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';
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;
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;
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "WASM solver not loaded",
} as SolutionMessage);
return;
}
if (atomic_nonce === null || atomic_solution === null) {
throw new Error("Atomics not initialized");
}
const { strategy } = event.data;
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);
};

View File

@@ -0,0 +1,60 @@
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;

1
packages/widget/src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"declaration": true,
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,29 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
build: {
target: 'es2022',
lib: {
entry: resolve(__dirname, 'src/entry.ts'),
formats: ['es'],
fileName: 'impost',
},
rollupOptions: {
external: [/^lit/, /^@lit\//],
output: {
preserveModules: false,
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,
},
plugins: [
dts(),
],
});

2
solver/.gitignore vendored Normal file
View File

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

46
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 wasm_target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
.cpu_features_add = std.Target.wasm.featureSet(&.{ .bulk_memory, .bulk_memory_opt, .simd128, .tail_call }),
});
// solver
const solver_mod = b.addModule("solver", .{
.root_source_file = b.path("src/solver.zig"),
.optimize = optimize,
.target = wasm_target,
});
const solver_exe = b.addExecutable(.{
.name = "solver",
.root_module = solver_mod,
});
solver_exe.entry = .disabled;
solver_exe.rdynamic = true;
solver_exe.lto = .full;
solver_exe.link_gc_sections = true;
b.installArtifact(solver_exe);
// validator
const validator_mod = b.addModule("validator", .{
.root_source_file = b.path("src/validator.zig"),
.optimize = optimize,
.target = wasm_target,
});
const validator_exe = b.addLibrary(.{
.name = "validator",
.root_module = validator_mod,
});
validator_exe.entry = .disabled;
validator_exe.rdynamic = true;
b.installArtifact(validator_exe);
}

31
solver/src/hasher.zig Normal file
View File

@@ -0,0 +1,31 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
var argon2_params = std.crypto.pwhash.argon2.Params{
.t = 4, // time cost
.m = 256, // 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;
fn bytesToHex(bytes: []const u8, output: []u8) void {
const hex_chars = "0123456789abcdef";
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
output[i * 2] = hex_chars[(bytes[i] >> 4)];
output[i * 2 + 1] = hex_chars[bytes[i] & 0x0F];
}
}
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);
var hash_bytes: [32]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(&derived, @ptrCast(hash_bytes[0..].ptr), .{});
bytesToHex(&hash_bytes, &buffer_hash_hex);
return buffer_hash_hex[0..];
}

7
solver/src/kctf.zig Normal file
View File

@@ -0,0 +1,7 @@
// A PoW algorithm based on google's kCTF scheme
// https://google.github.io/kctf/
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const math = std.math;

159
solver/src/solver.zig Normal file
View File

@@ -0,0 +1,159 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const hasher = @import("hasher.zig");
// var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
// var allocator = gpa.allocator();
var allocator = std.heap.wasm_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;
// fn log(comptime fmt: []const u8, args: anytype) void {
// const formatted = std.fmt.allocPrint(allocator, fmt, args) catch return;
// __log(@intFromPtr(formatted.ptr), formatted.len);
// allocator.free(formatted);
// }
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]);
}
}
const SolveError = enum(u32) {
InvalidDifficulty = 1,
InvalidNonce = 2,
NoSolution = 3,
OutOfMemory = 4,
};
var solve_error: ?SolveError = null;
export fn get_solve_error() u32 {
if (solve_error) |err| {
return @intFromEnum(err);
}
return 0;
}
// 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 {
solve_error = null;
const challenge_slice = challenge_ptr[0..challenge_len];
if (difficulty < 1 or difficulty > 64) {
solve_error = SolveError.InvalidDifficulty;
return -1;
}
var target_prefix_buffer: [64]u8 = @splat('0');
const target_prefix = target_prefix_buffer[0..difficulty];
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 {
// log("Failed to allocate memory for challenge\n", .{});
solve_error = SolveError.OutOfMemory;
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 {
solve_error = SolveError.InvalidNonce;
return -1;
};
const hash_hex_slice = hasher.hash(allocator, input_buffer[0..challenge_len], input_buffer[challenge_len .. challenge_len + nonce_str.len]) catch {
solve_error = SolveError.OutOfMemory;
return -1;
};
if (std.mem.startsWith(u8, hash_hex_slice, target_prefix)) {
// 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;
}
}
}
solve_error = SolveError.NoSolution;
return -1;
}
export fn solve_target_number_challenge(target_ptr: [*]u8, target_len: usize, salt_ptr: [*]u8, salt_len: usize) i32 {
solve_error = null;
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 {
solve_error = SolveError.OutOfMemory;
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 {
solve_error = SolveError.InvalidNonce;
return -1;
};
const hash_hex_slice = hasher.hash(allocator, input_buffer[0..salt_len], input_buffer[salt_len .. salt_len + nonce_str.len]) catch {
solve_error = SolveError.OutOfMemory;
return -1;
};
if (std.mem.eql(u8, target_slice, hash_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;
}
}
}
solve_error = SolveError.NoSolution;
return -1;
}

81
solver/src/validator.zig Normal file
View File

@@ -0,0 +1,81 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const hasher = @import("hasher.zig");
var allocator = std.heap.wasm_allocator;
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]);
}
}
fn bytesToHex(bytes: []const u8, buf: []u8) void {
const hex_chars = "0123456789abcdef";
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
buf[i * 2] = hex_chars[(bytes[i] >> 4)];
buf[i * 2 + 1] = hex_chars[bytes[i] & 0x0F];
}
}
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;
}
var target_prefix_buffer: [64]u8 = @splat('0');
const target_prefix = target_prefix_buffer[0..difficulty];
const hash_hex_slice = hasher.hash(allocator, challenge_slice, nonce_slice) catch return -2;
if (!std.mem.startsWith(u8, hash_hex_slice, target_prefix)) {
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 hash_hex_slice = hasher.hash(allocator, salt_slice, nonce_slice) catch return -2;
if (!std.mem.eql(u8, target_slice, hash_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 hash_slice = hasher.hash(allocator, challenge, nonce) catch return 0;
// 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 = hash_slice.len;
ret <<= 32;
ret |= @intFromPtr(hash_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)});
// }