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.

target difficulty can be implemented in a later step, this change
only check if there is any valid nonce tag with commitment target
greater than 0.
pull/55/head
OFF0 1 year ago
parent 7edf1151a6
commit a1b1f3baee
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/
@ -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,52 @@ profileForm.addEventListener('submit', async (e) => {
}).catch(console.error);
}
});
/**
* validate proof-of-work of a nostr event per nip-13.
* the validation always requires difficulty commitment in the nonce tag.
*
* @param {EventObj} evt event to validate
* TODO: @param {number} targetDifficulty target proof-of-work difficulty
*/
function validatePow(evt) {
const tag = evt.tags.find(tag => tag[0] === 'nonce');
if (!tag) {
return false;
}
const difficultyCommitment = Number(tag[2]);
if (!difficultyCommitment || Number.isNaN(difficultyCommitment)) {
return false;
}
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 = 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});
}
});
Loading…
Cancel
Save