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 cdb2b44..5bb97e0 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ -import {relayPool, generatePrivateKey, getEventHash, getPublicKey, signEvent} from 'nostr-tools'; +import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './cryptoutils'; import {elem, parseTextContent} from './domutil.js'; import {dateTime, formatTime} from './timeutil.js'; // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ @@ -40,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 = []; @@ -763,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); @@ -803,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); @@ -945,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); @@ -967,29 +967,46 @@ profileForm.addEventListener('submit', async (e) => { } }); +/** + * check that the event has the id has the desired number of leading zero bits + * @param {EventObj} evt to validate + * @returns boolean + */ function validatePow(evt) { const tag = evt.tags.find(tag => tag[0] === 'nonce'); if (!tag) { return false; } - const [, , difficulty2] = tag; - if (difficulty2 < 16) { - return false; - } - return evt.id.substring(0, difficulty2 / 4) === '00'.repeat(difficulty2 / 8); -} + const [, , difficultyCommitment] = tag; + return zeroLeadingBitsCount(evt.id) === difficultyCommitment; +} + +/** + * 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) { + return new Promise((resolve, reject) => { + const worker = new Worker('./worker.js'); + + worker.onmessage = (msg) => { + worker.terminate(); + if (msg.data.error) { + reject(msg.data.error); + } else { + resolve(msg.data.event); + } + }; -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 + worker.onerror = (err) => { + worker.terminate(); + reject(err); + }; + + worker.postMessage({event: evt, difficulty, timeout}); + }); +} diff --git a/src/worker.js b/src/worker.js new file mode 100644 index 0000000..ca1e9f0 --- /dev/null +++ b/src/worker.js @@ -0,0 +1,47 @@ +import {getEventHash} from 'nostr-tools'; +import {zeroLeadingBitsCount} from './cryptoutils.js'; + +function mine(event, difficulty, 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); + if (zeroLeadingBitsCount(id) === difficulty) { + console.log(event.tags[0][1], id); + console.timeEnd('pow'); + return event; + } + } +} + +addEventListener('message', async (msg) => { + const { + difficulty, + event, + timeout, + } = msg.data; + try { + const minedEvent = mine(event, difficulty, timeout); + postMessage({event: minedEvent}); + } catch (err) { + postMessage({error: err}); + } +}); \ No newline at end of file