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:
1
example-app/.example.env
Normal file
1
example-app/.example.env
Normal file
@@ -0,0 +1 @@
|
||||
YAPTCHA_HMAC_SECRET=xxx # openssl rand -base64 32
|
||||
5
example-app/.gitignore
vendored
Normal file
5
example-app/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.output/
|
||||
.nuxt/
|
||||
.env
|
||||
*.wasm
|
||||
6
example-app/.vscode/settings.json
vendored
Normal file
6
example-app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"minimap.background": "#00000000",
|
||||
"scrollbar.shadow": "#00000000"
|
||||
}
|
||||
}
|
||||
50
example-app/README.md
Normal file
50
example-app/README.md
Normal 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
17
example-app/app/app.vue
Normal 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>
|
||||
411
example-app/app/pages/index.vue
Normal file
411
example-app/app/pages/index.vue
Normal 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>
|
||||
38
example-app/app/pages/widget.vue
Normal file
38
example-app/app/pages/widget.vue
Normal 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>
|
||||
3
example-app/app/plugins/pow-captcha.ts
Normal file
3
example-app/app/plugins/pow-captcha.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import '@impost/widget';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => { });
|
||||
6
example-app/app/utils/worker-name.ts
Normal file
6
example-app/app/utils/worker-name.ts
Normal 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)]}`;
|
||||
}
|
||||
170
example-app/app/utils/worker.ts
Normal file
170
example-app/app/utils/worker.ts
Normal 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
2283
example-app/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
7
example-app/config.toml
Normal file
7
example-app/config.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
strategy = "target_number"
|
||||
|
||||
[leading_zeroes]
|
||||
difficulty = 4
|
||||
|
||||
[target_number]
|
||||
max_number = 10000
|
||||
57
example-app/nuxt.config.ts
Normal file
57
example-app/nuxt.config.ts
Normal 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
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
27
example-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
example-app/server/api/pow/challenge.get.ts
Normal file
44
example-app/server/api/pow/challenge.get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
45
example-app/server/api/pow/challenge.post.ts
Normal file
45
example-app/server/api/pow/challenge.post.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
19
example-app/server/api/pow/difficulty.get.ts
Normal file
19
example-app/server/api/pow/difficulty.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
35
example-app/server/api/pow/difficulty.put.ts
Normal file
35
example-app/server/api/pow/difficulty.put.ts
Normal 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'
|
||||
};
|
||||
});
|
||||
10
example-app/server/middleware/secure-context.ts
Normal file
10
example-app/server/middleware/secure-context.ts
Normal 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', '*');
|
||||
});
|
||||
42
example-app/server/utils/config.ts
Normal file
42
example-app/server/utils/config.ts
Normal 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);
|
||||
}
|
||||
6
example-app/server/utils/pow.ts
Normal file
6
example-app/server/utils/pow.ts
Normal 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
17
example-app/tsconfig.json
Normal 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
11
example-app/uno.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig, presetMini, presetIcons, transformerDirectives } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetMini(),
|
||||
presetIcons(),
|
||||
],
|
||||
transformers: [
|
||||
transformerDirectives(),
|
||||
]
|
||||
})
|
||||
Reference in New Issue
Block a user