diff --git a/esbuildconf.js b/esbuildconf.js index 8062f34..59e5fe4 100644 --- a/esbuildconf.js +++ b/esbuildconf.js @@ -20,6 +20,7 @@ export const options = { 'src/main.css', 'src/main.js', 'src/manifest.json', + 'src/worker.js', ], outdir: 'dist', //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths diff --git a/src/cryptoutils.js b/src/cryptoutils.js new file mode 100644 index 0000000..2c2b265 --- /dev/null +++ b/src/cryptoutils.js @@ -0,0 +1,24 @@ +/** + * evaluate the difficulty of hex32 according to nip-13. + * @param hex32 a string of 64 chars - 32 bytes in hex representation + */ +export const zeroLeadingBitsCount = (hex32) => { + let count = 0; + for (let i = 0; i < 64; i += 2) { + const hexbyte = hex32.slice(i, i + 2); // grab next byte + if (hexbyte == '00') { + count += 8; + continue; + } + // reached non-zero byte; count number of 0 bits in hexbyte + const bits = parseInt(hexbyte, 16).toString(2).padStart(8, '0'); + for (let b = 0; b < 8; b++) { + if (bits[b] == '1' ) { + break; // reached non-zero bit; stop + } + count += 1; + } + break; + } + return count; +}; diff --git a/src/main.js b/src/main.js index f5366f5..c6ce965 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import {relayPool, generatePrivateKey, getEventHash, getPublicKey, signEvent} from 'nostr-tools'; +import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {elem, parseTextContent} from './domutil.js'; import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -41,6 +41,8 @@ let pubkey = localStorage.getItem('pub_key') || (() => { return pubkey; })(); +// arbitrary difficulty, still experimenting. +// measured empirically, takes N sec on average to mine a text note event. const difficulty = 16; const subList = []; @@ -764,14 +766,11 @@ function hideNewMessage(hide) { async function upvote(eventId, relay) { const privatekey = localStorage.getItem('private_key'); - const newReaction = powEvent({ + const newReaction = await powEvent({ kind: 7, pubkey, // TODO: lib could check that this is the pubkey of the key to sign with content: '+', - tags: [ - ['nonce', '0', `${difficulty}`], - ['e', eventId, relay, 'reply'], - ], + tags: [['e', eventId, relay, 'reply']], created_at: Math.floor(Date.now() * 0.001), }, difficulty); const sig = await signEvent(newReaction, privatekey).catch(console.error); @@ -804,11 +803,11 @@ writeForm.addEventListener('submit', async (e) => { } const replyTo = localStorage.getItem('reply_to'); const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; - const newEvent = powEvent({ + const newEvent = await powEvent({ kind: 1, content, pubkey, - tags: [['nonce', '0', `${difficulty}`], ...tags], + tags, created_at: Math.floor(Date.now() * 0.001), }, difficulty); const sig = await signEvent(newEvent, privatekey).catch(onSendError); @@ -946,11 +945,11 @@ profileForm.addEventListener('submit', async (e) => { const form = new FormData(profileForm); const privatekey = localStorage.getItem('private_key'); - const newProfile = powEvent({ + const newProfile = await powEvent({ kind: 0, pubkey, content: JSON.stringify(Object.fromEntries(form)), - tags: [['nonce', '0', `${difficulty}`]], + tags: [], created_at: Math.floor(Date.now() * 0.001), }, difficulty); const sig = await signEvent(newProfile, privatekey).catch(console.error); @@ -980,17 +979,36 @@ function validatePow(evt) { return evt.id.substring(0, difficulty2 / 4) === '00'.repeat(difficulty2 / 8); } -function powEvent(newEvent, difficulty) { - const chars = difficulty / 8; - let n = Number(newEvent.tags[0][1]) + 1; - // const until = Date.now() + 15000; - console.time('pow'); - while (true/*Date.now() < until*/) { - newEvent.tags[0][1] = `${n++}`; - const id = getEventHash(newEvent, privatekey); - if (id.substring(0, chars * 2) === '00'.repeat(chars)) { - console.timeEnd('pow'); - return newEvent; - } - } -} \ No newline at end of file +/** + * run proof of work in a worker until at least the specified difficulty. + * if succcessful, the returned event contains the 'nonce' tag + * and the updated created_at timestamp. + * + * powEvent returns a rejected promise if the funtion runs for longer than timeout. + * a zero timeout makes mineEvent run without a time limit. + */ +function powEvent(evt, difficulty, timeout) { + const privatekey = localStorage.getItem('private_key'); + return new Promise((resolve, reject) => { + // const webWorkerURL = URL.createObjectURL(new Blob(['(', powEventWorker(), ')()'], {type: 'application/javascript'})); + // const worker = new Worker(webWorkerURL); + const worker = new Worker('./worker.js'); + + worker.onmessage = (msg) => { + worker.terminate(); + if (msg.data.error) { + reject(msg.data.error); + } else { + resolve(msg.data.event); + } + }; + + worker.onerror = (err) => { + worker.terminate(); + reject(err); + }; + + worker.postMessage({event: evt, difficulty, privatekey, timeout}); + // URL.revokeObjectURL(webWorkerURL); // one-time worker; no longer need the URL obj + }); +} diff --git a/src/worker.js b/src/worker.js new file mode 100644 index 0000000..cad755f --- /dev/null +++ b/src/worker.js @@ -0,0 +1,48 @@ +import {getEventHash} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './cryptoutils.js'; + +function mine(event, difficulty, privatekey, timeout) { + const max = 256; // arbitrary + if (!Number.isInteger(difficulty) || difficulty < 0 || difficulty > max) { + throw new Error(`difficulty must be an integer between 0 and ${max}`); + } + // continue with mining + let n = BigInt(0); + event.tags.unshift(['nonce', n.toString(), `${difficulty}`]); + + const start = Math.floor(Date.now() * 0.001); + console.time('pow'); + while (true) { + const now = Math.floor(Date.now() * 0.001); + // if (now > start + 15) { + // console.timeEnd('pow'); + // return false; + // } + if (now !== event.created_at) { + event.created_at = now; + // n = BigInt(0); // could reset nonce as we have a new timestamp + } + event.tags[0][1] = (++n).toString(); + const id = getEventHash(event, privatekey); + if (zeroLeadingBitsCount(id) === difficulty) { + console.log(event.tags[0][1], id); + console.timeEnd('pow'); + return event; + } + } +} + +addEventListener('message', async (msg) => { + const { + difficulty, + event, + privatekey, + timeout, + } = msg.data; + try { + const minedEvent = mine(event, difficulty, privatekey, timeout); + postMessage({event: minedEvent}); + } catch (err) { + postMessage({error: err}); + } +}); \ No newline at end of file