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.
parent
7edf1151a6
commit
998b9dbf58
|
@ -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;
|
||||
};
|
75
src/main.js
75
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});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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…
Reference in New Issue