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.
OFF0 2 years ago
parent 89c54ac08f
commit ee510c17e7
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -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

@ -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;
}

@ -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;
};

@ -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/
@ -40,6 +41,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());
@ -161,9 +166,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;
}
}
@ -450,7 +455,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',
@ -626,7 +631,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)));
@ -686,7 +691,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,
@ -761,13 +766,13 @@ function hideNewMessage(hide) {
async function upvote(eventId, relay) {
const privatekey = localStorage.getItem('private_key');
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: [['e', eventId, relay, 'reply']],
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) => {
@ -798,13 +803,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) => {
@ -940,13 +945,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) => {
@ -961,3 +966,47 @@ profileForm.addEventListener('submit', async (e) => {
}).catch(console.error);
}
});
/**
* 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 [, , 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});
});
}

@ -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});
}
});
Loading…
Cancel
Save