From 998b9dbf58884aa78a68c03b7f71acb6da53858f Mon Sep 17 00:00:00 2001 From: OFF0 Date: Sat, 24 Dec 2022 00:02:57 +0100 Subject: [PATCH] nip-13: mine pow async in worker any only invoke noxy with pow added pow to text notes, reactions and metadata events. pow is mined async in a worker so that the main process does not freeze. noxy profile images, link and image previews are now now only invoked if an event has some valid work proof. noxy can decide if there is enough work and whether or not to serve data for a certain event. --- esbuildconf.js | 1 + src/cards.css | 2 -- src/cryptoutils.js | 24 +++++++++++++++ src/main.js | 75 ++++++++++++++++++++++++++++++++++++++-------- src/worker.js | 47 +++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 src/cryptoutils.js create mode 100644 src/worker.js 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/cards.css b/src/cards.css index 2a407b4..c3a7a58 100644 --- a/src/cards.css +++ b/src/cards.css @@ -158,6 +158,4 @@ max-width: 48rem; padding: 1.5rem 1.8rem; width: 100%; - /* TODO: revert when things calm down or we find an alternative */ - display: none; } 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 4a46852..88140fe 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ 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/ @@ -39,6 +40,10 @@ 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 = []; const unSubAll = () => { subList.forEach(sub => sub.unsub()); @@ -160,9 +165,9 @@ function renderProfile(evt, relay) { if (content) { profileAbout.textContent = content.about; profileName.textContent = content.name; - const noxyImg = getNoxyUrl('data', content.picture, evt.id, relay); + const noxyImg = validatePow(evt) && getNoxyUrl('data', content.picture, evt.id, relay); if (noxyImg) { - profileImage.setAttribute('src', getNoxyUrl('data', noxyImg, evt.id, relay)); + profileImage.setAttribute('src', noxyImg); profileImage.hidden = false; } } @@ -439,7 +444,7 @@ function createTextNote(evt, relay) { ]), elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ ...content, - firstLink ? linkPreview(firstLink, evt.id, relay) : '' + (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '', ]), elem('button', { className: 'btn-inline', name: 'star', type: 'button', @@ -615,7 +620,7 @@ function setMetadata(evt, relay, content) { } } // update profile images - if (user.picture) { + if (user.picture && validatePow(evt)) { document.body .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); @@ -675,7 +680,7 @@ function getMetadata(evt, relay) { const name = user?.metadata[relay]?.name; const userName = name || evt.pubkey.slice(0, 8); const userAbout = user?.metadata[relay]?.about || ''; - const img = userImg ? elem('img', { + const img = (userImg && validatePow(evt)) ? elem('img', { alt: `${userName} ${host}`, loading: 'lazy', src: userImg, @@ -757,13 +762,13 @@ async function upvote(eventId, eventPubkey) { .map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields ['e', eventId], ['p', eventPubkey], // last e and p tag is the id and pubkey of the note being reacted to (nip-25) ]; - const newReaction = { + const newReaction = await powEvent({ kind: 7, pubkey, // TODO: lib could check that this is the pubkey of the key to sign with content: '+', tags, created_at: Math.floor(Date.now() * 0.001), - }; + }, difficulty); const sig = await signEvent(newReaction, privatekey).catch(console.error); if (sig) { const ev = await pool.publish({...newReaction, sig}, (status, url) => { @@ -794,13 +799,13 @@ writeForm.addEventListener('submit', async (e) => { } const replyTo = localStorage.getItem('reply_to'); const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; - const newEvent = { + const newEvent = await powEvent({ kind: 1, - pubkey, content, + pubkey, tags, created_at: Math.floor(Date.now() * 0.001), - }; + }, difficulty); const sig = await signEvent(newEvent, privatekey).catch(onSendError); if (sig) { const ev = await pool.publish({...newEvent, sig}, (status, url) => { @@ -936,13 +941,13 @@ profileForm.addEventListener('submit', async (e) => { const form = new FormData(profileForm); const privatekey = localStorage.getItem('private_key'); - const newProfile = { + const newProfile = await powEvent({ kind: 0, pubkey, content: JSON.stringify(Object.fromEntries(form)), - created_at: Math.floor(Date.now() * 0.001), tags: [], - }; + created_at: Math.floor(Date.now() * 0.001), + }, difficulty); const sig = await signEvent(newProfile, privatekey).catch(console.error); if (sig) { const ev = await pool.publish({...newProfile, sig}, (status, url) => { @@ -957,3 +962,47 @@ profileForm.addEventListener('submit', async (e) => { }).catch(console.error); } }); + +/** + * validate the difficulty target matches the 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 [, , 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); + } + }; + + 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..c72967c --- /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 = 5) { + 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