nip-13: add pow and only invoke noxy in events with valid work #55

Merged
offbyn merged 5 commits from nip-13 into master 2 years ago

@ -20,6 +20,7 @@ export const options = {
'src/main.css', 'src/main.css',
'src/main.js', 'src/main.js',
'src/manifest.json', 'src/manifest.json',
'src/worker.js',
], ],
outdir: 'dist', outdir: 'dist',
//entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths //entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths

@ -10,7 +10,7 @@
} }
@media (orientation: portrait) { @media (orientation: portrait) {
.mbox { .mbox {
padding: 0 calc(.5 * var(--gap)); padding: 0 var(--gap-half);
} }
} }
.mbox:last-child { .mbox:last-child {
@ -97,6 +97,16 @@
overflow: visible; overflow: visible;
position: relative; position: relative;
} }
.mbox .buttons {
margin-top: .2rem;
}
.mbox button:not(#publish) {
--bg-color: none;
--border-color: none;
}
.mbox button img + small {
padding-left: .5rem;
}
.mobx-replies { .mobx-replies {
flex-grow: 1; flex-grow: 1;
position: relative; position: relative;
@ -158,6 +168,4 @@
max-width: 48rem; max-width: 48rem;
padding: 1.5rem 1.8rem; padding: 1.5rem 1.8rem;
width: 100%; 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;
};

@ -0,0 +1,36 @@
#errorOverlay {
background: var(--bgcolor-danger);
bottom: 0;
display: flex;
flex-direction: column;
left: 0;
overflow: auto;
padding: var(--gap);
position: fixed;
right: 0;
top: 0;
z-index: 100;
}
.error-title {
margin-top: 0;
}
#errorOverlay button {
background-color: rgba(0 0 0 / .5);
border: none;
display: inline-block;
}
#errorOverlay button:focus {
outline: 2px solid white;
outline-offset: var(--focus-outline-offset);
}
#errorOverlay .buttons {
max-width: var(--max-width);
}
@media (orientation: portrait) {
#errorOverlay .buttons {
flex-basis: 4rem;
}
}

@ -43,6 +43,7 @@ label {
transition: background-color var(--transition-duration); transition: background-color var(--transition-duration);
} }
input[type="number"],
input[type="password"], input[type="password"],
input[type="text"], input[type="text"],
input[type="url"], input[type="url"],
@ -54,6 +55,7 @@ textarea {
margin: 0 0 1.2rem 0; margin: 0 0 1.2rem 0;
padding: var(--padding); padding: var(--padding);
} }
input[type="number"]:focus,
input[type="password"]:focus, input[type="password"]:focus,
input[type="text"]:focus, input[type="text"]:focus,
input[type="url"]:focus, input[type="url"]:focus,
@ -95,14 +97,22 @@ textarea:focus {
align-items: center; align-items: center;
display: flex; display: flex;
flex-basis: 100%; flex-basis: 100%;
justify-content: flex-end;
gap: var(--gap); gap: var(--gap);
margin-top: 2rem; justify-content: start;
margin-top: var(--gap-half);
min-height: 3.2rem; min-height: 3.2rem;
} }
form .buttons,
.form .buttons,
.form-inline .buttons { .form-inline .buttons {
flex-basis: fit-content; flex-basis: fit-content;
margin-top: 0; justify-content: end;
}
.buttons img,
.buttons small,
.buttons span {
vertical-align: middle;
} }
button { button {
@ -121,24 +131,11 @@ button:focus {
.btn-inline { .btn-inline {
--border-color: transparent; --border-color: transparent;
align-items: center;
background: transparent; background: transparent;
color: var(--color); color: var(--color-accent);
display: inline-flex; display: inline-block;
gap: .5ch;
line-height: 1; line-height: 1;
padding: .6rem; padding: 0 .6rem;
}
.btn-inline img {
max-height: 18px;
max-width: 18px;
}
.btn-inline img[alt] {
color: #7f7f7f;
line-height: 1px;
}
.btn-inline img[alt]::before {
font-size: 3.4rem;
} }
.btn-danger { .btn-danger {
@ -154,6 +151,7 @@ button:disabled {
.form-status { .form-status {
flex-basis: 100%; flex-basis: 100%;
flex-grow: 1; flex-grow: 1;
min-height: 1.8rem;
padding: var(--padding); padding: var(--padding);
} }
@ -169,11 +167,13 @@ button:disabled {
margin-left: var(--gap); margin-left: var(--gap);
} }
.form-inline button, .form-inline button,
.form-inline input[type="number"],
.form-inline input[type="text"], .form-inline input[type="text"],
.form-inline textarea { .form-inline textarea {
margin: .4rem 0; margin: .4rem 0;
} }
.form-inline input[type="number"],
.form-inline input[type="text"], .form-inline input[type="text"],
.form-inline textarea { .form-inline textarea {
flex-basis: 50%; flex-basis: 50%;
@ -187,6 +187,46 @@ button:disabled {
flex-grow: 0; flex-grow: 0;
} }
label.number {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: var(--gap);
padding: 0;
}
* + label.number {
margin: var(--gap) 0 0 0;
}
label.number span {
flex-grow: 1;
padding: 0 0 0 var(--padding);
}
label.number input[type="number"] {
align-self: baseline;
margin-bottom: 0;
}
@media (orientation: landscape) {
label.number span {
align-self: center;
}
label.number input[type="number"] + span {
padding: 0 var(--padding) 0 0;
}
}
@media (orientation: portrait) {
label.number {
flex-direction: column;
gap: var(--gap-half);
padding: 0;
}
label.number span {
padding: 0 var(--padding);
}
label.number input[type="number"] {
align-self: stretch;
}
}
button#publish { button#publish {
align-self: end; align-self: end;
order: 2; order: 2;

@ -83,6 +83,27 @@
<button type="submit" name="publish" tabindex="0" disabled>publish</button> <button type="submit" name="publish" tabindex="0" disabled>publish</button>
</div> </div>
</form> </form>
<form action="#" name="options">
<label class="number" for="miningTarget">
<span>
mining difficulty<br>
<small>
with which difficulty to try to mine a proof of work when publishing events, such as: notes, replies, reactions and profile updates.
use zero to disable mining.
difficulty is defined as the number of leading zero bits, read more about
<a href="https://github.com/nostr-protocol/nips/blob/master/13.md" target="_blank" rel="noopener noreferrer">proof of work (nip-13)</a>.
</small>
</span>
<input type="number" name="mining_target" step="1" min="0" max="256" id="miningTarget" value="16">
</label>
<label class="number" for="miningTimeout">
<span>
mining timeout<br>
<small>abort trying to find a proof if timeout (in seconds) exceeds. use 0 to mine without a time limit.</small>
</span>
<input type="number" name="mining_timeout" step="1" min="0" max="256" id="miningTimeout" value="5">
</label>
</form>
<form action="#" name="settings" autocomplete="new-password"> <form action="#" name="settings" autocomplete="new-password">
<label for="pubkey">public-key</label> <label for="pubkey">public-key</label>
<input type="text" id="pubkey" autocomplete="off"> <input type="text" id="pubkey" autocomplete="off">
@ -106,6 +127,7 @@
</footer> </footer>
</div> </div>
</div> </div>
<div id="errorOverlay" class="form" hidden></div>
</main> </main>
</body> </body>

@ -2,6 +2,7 @@
@import "cards.css"; @import "cards.css";
@import "form.css"; @import "form.css";
@import "write.css"; @import "write.css";
@import "error.css";
:root { :root {
/* 5px auto Highlight */ /* 5px auto Highlight */
@ -14,6 +15,8 @@
--focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color);
--font-small: 1.2rem; --font-small: 1.2rem;
--gap: 2.4rem; --gap: 2.4rem;
--gap-half: 1.2rem;
--max-width: 96ch;
} }
::selection { ::selection {

@ -1,4 +1,5 @@
import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools'; import {relayPool, generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools';
import {zeroLeadingBitsCount} from './cryptoutils';
import {elem, parseTextContent} from './domutil.js'; import {elem, parseTextContent} from './domutil.js';
import {dateTime, formatTime} from './timeutil.js'; import {dateTime, formatTime} from './timeutil.js';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/ // curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
@ -160,9 +161,9 @@ function renderProfile(evt, relay) {
if (content) { if (content) {
profileAbout.textContent = content.about; profileAbout.textContent = content.about;
profileName.textContent = content.name; 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) { if (noxyImg) {
profileImage.setAttribute('src', getNoxyUrl('data', noxyImg, evt.id, relay)); profileImage.setAttribute('src', noxyImg);
profileImage.hidden = false; profileImage.hidden = false;
} }
} }
@ -216,7 +217,7 @@ document.body.addEventListener('click', (e) => {
writeInput.blur(); writeInput.blur();
return; return;
} }
appendReplyForm(button); appendReplyForm(button.closest('.buttons'));
localStorage.setItem('reply_to', id); localStorage.setItem('reply_to', id);
return; return;
} }
@ -439,28 +440,26 @@ function createTextNote(evt, relay) {
]), ]),
elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [ elem('div', {/* data: isLongContent ? {append: evt.content.slice(280)} : null*/}, [
...content, ...content,
firstLink ? linkPreview(firstLink, evt.id, relay) : '' (firstLink && validatePow(evt)) ? linkPreview(firstLink, evt.id, relay) : '',
]), ]),
elem('button', { elem('div', {className: 'buttons'}, [
className: 'btn-inline', name: 'star', type: 'button', elem('button', {name: 'reply', type: 'button'}, [
data: {'eventId': evt.id, relay}, elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})
}, [ ]),
elem('button', {name: 'star', type: 'button'}, [
elem('img', { elem('img', {
alt: didReact ? '✭' : '✩', // ♥ alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24, height: 24, width: 24,
src: `assets/${didReact ? 'star-fill' : 'star'}.svg`, src: `assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionList(evt.id).join(' '), title: getReactionList(evt.id).join(' '),
}), }),
elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''), elem('small', {data: {reactions: ''}}, hasReactions ? reactionMap[evt.id].length : ''),
]),
]), ]),
elem('button', {
className: 'btn-inline', name: 'reply', type: 'button',
data: {'eventId': evt.id, relay},
}, [elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})]),
// replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '', // replies[0] ? elem('div', {className: 'mobx-replies'}, replyFeed.reverse()) : '',
]); ]);
if (restoredReplyTo === evt.id) { if (restoredReplyTo === evt.id) {
appendReplyForm(body.querySelector('button[name="reply"]')); appendReplyForm(body.querySelector('.buttons'));
requestAnimationFrame(() => updateElemHeight(writeInput)); requestAnimationFrame(() => updateElemHeight(writeInput));
} }
return renderArticle([ return renderArticle([
@ -479,7 +478,7 @@ function handleReply(evt, relay) {
} }
function renderReply(evt, relay) { function renderReply(evt, relay) {
const eventId = evt.tags[0][1]; // TODO: double check const eventId = evt.tags.filter(hasEventTag)[0][1]; // TODO: should check for 'reply' marker with fallback to 'root' marker or last 'e' tag, see nip-10
const article = feedDomMap[eventId] || replyDomMap[eventId]; const article = feedDomMap[eventId] || replyDomMap[eventId];
if (!article) { // root article has not been rendered if (!article) { // root article has not been rendered
return; return;
@ -615,7 +614,7 @@ function setMetadata(evt, relay, content) {
} }
} }
// update profile images // update profile images
if (user.picture) { if (user.picture && validatePow(evt)) {
document.body document.body
.querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`) .querySelectorAll(`canvas[data-pubkey="${evt.pubkey}"]`)
.forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas))); .forEach(canvas => (canvas.parentNode.replaceChild(elem('img', {src: user.picture}), canvas)));
@ -675,14 +674,14 @@ function getMetadata(evt, relay) {
const name = user?.metadata[relay]?.name; const name = user?.metadata[relay]?.name;
const userName = name || evt.pubkey.slice(0, 8); const userName = name || evt.pubkey.slice(0, 8);
const userAbout = user?.metadata[relay]?.about || ''; const userAbout = user?.metadata[relay]?.about || '';
const img = userImg ? elem('img', { const img = (userImg && validatePow(evt)) ? elem('img', {
alt: `${userName} ${host}`, alt: `${userName} ${host}`,
loading: 'lazy', loading: 'lazy',
src: userImg, src: userImg,
title: `${userName} on ${host} ${userAbout}`, title: `${userName} on ${host} ${userAbout}`,
}) : elemCanvas(evt.pubkey); }) : elemCanvas(evt.pubkey);
const isReply = evt.tags.some(hasEventTag); const isReply = evt.tags.some(hasEventTag);
const replies = replyList.filter((reply) => reply.tags[0][1] === evt.id); const replies = replyList.filter(({tags}) => tags.filter(hasEventTag).some(reply => reply[1] === evt.id)); // TODO: nip-10
const time = new Date(evt.created_at * 1000); const time = new Date(evt.created_at * 1000);
return {host, img, isReply, name, replies, time, userName}; return {host, img, isReply, name, replies, time, userName};
} }
@ -724,6 +723,9 @@ function appendReplyForm(el) {
requestAnimationFrame(() => writeInput.focus()); requestAnimationFrame(() => writeInput.focus());
} }
const lockScroll = () => document.body.style.overflow = 'hidden';
const unlockScroll = () => document.body.style.removeProperty('overflow');
const newMessageDiv = document.querySelector('#newMessage'); const newMessageDiv = document.querySelector('#newMessage');
document.querySelector('#bubble').addEventListener('click', (e) => { document.querySelector('#bubble').addEventListener('click', (e) => {
localStorage.removeItem('reply_to'); // should it forget old replyto context? localStorage.removeItem('reply_to'); // should it forget old replyto context?
@ -733,7 +735,7 @@ document.querySelector('#bubble').addEventListener('click', (e) => {
if (writeInput.value.trimRight()) { if (writeInput.value.trimRight()) {
writeInput.style.removeProperty('height'); writeInput.style.removeProperty('height');
} }
document.body.style.overflow = 'hidden'; lockScroll();
requestAnimationFrame(() => updateElemHeight(writeInput)); requestAnimationFrame(() => updateElemHeight(writeInput));
}); });
@ -744,12 +746,28 @@ document.body.addEventListener('keyup', (e) => {
}); });
function hideNewMessage(hide) { function hideNewMessage(hide) {
document.body.style.removeProperty('overflow'); unlockScroll();
newMessageDiv.hidden = hide; newMessageDiv.hidden = hide;
} }
// arbitrary difficulty default, still experimenting.
let difficulty = JSON.parse(localStorage.getItem('mining_target')) ?? 16;
const miningTargetInput = document.querySelector('#miningTarget');
miningTargetInput.addEventListener('input', (e) => {
localStorage.setItem('mining_target', miningTargetInput.valueAsNumber);
difficulty = miningTargetInput.valueAsNumber;
});
miningTargetInput.value = difficulty;
let timeout = JSON.parse(localStorage.getItem('mining_timeout')) ?? 5;
const miningTimeoutInput = document.querySelector('#miningTimeout');
miningTimeoutInput.addEventListener('input', (e) => {
localStorage.setItem('mining_timeout', miningTimeoutInput.valueAsNumber);
timeout = miningTimeoutInput.valueAsNumber;
});
miningTimeoutInput.value = timeout;
async function upvote(eventId, eventPubkey) { async function upvote(eventId, eventPubkey) {
const privatekey = localStorage.getItem('private_key');
const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId)); const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId));
const tags = [ const tags = [
...note.tags ...note.tags
@ -757,15 +775,26 @@ async function upvote(eventId, eventPubkey) {
.map(([a, b]) => [a, b]), // drop optional (nip-10) relay and marker fields .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) ['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 article = (feedDomMap[eventId] || replyDomMap[eventId]);
const reactionBtn = article.querySelector('[name="star"]');
const statusElem = article.querySelector('[data-reactions]');
reactionBtn.disabled = true;
const newReaction = await powEvent({
kind: 7, kind: 7,
pubkey, // TODO: lib could check that this is the pubkey of the key to sign with pubkey, // TODO: lib could check that this is the pubkey of the key to sign with
content: '+', content: '+',
tags, tags,
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}; }, {difficulty, statusElem, timeout}).catch(console.warn);
if (!newReaction) {
statusElem.textContent = reactionMap[eventId]?.length;
reactionBtn.disabled = false;
return;
}
const privatekey = localStorage.getItem('private_key');
const sig = await signEvent(newReaction, privatekey).catch(console.error); const sig = await signEvent(newReaction, privatekey).catch(console.error);
if (sig) { if (sig) {
statusElem.textContent = 'publishing…';
const ev = await pool.publish({...newReaction, sig}, (status, url) => { const ev = await pool.publish({...newReaction, sig}, (status, url) => {
if (status === 0) { if (status === 0) {
console.info(`publish request sent to ${url}`); console.info(`publish request sent to ${url}`);
@ -774,6 +803,7 @@ async function upvote(eventId, eventPubkey) {
console.info(`event published by ${url}`); console.info(`event published by ${url}`);
} }
}).catch(console.error); }).catch(console.error);
reactionBtn.disabled = false;
} }
} }
@ -793,30 +823,38 @@ writeForm.addEventListener('submit', async (e) => {
return onSendError(new Error('message is empty')); return onSendError(new Error('message is empty'));
} }
const replyTo = localStorage.getItem('reply_to'); const replyTo = localStorage.getItem('reply_to');
const close = () => {
sendStatus.textContent = '';
writeInput.value = '';
writeInput.style.removeProperty('height');
publish.disabled = true;
if (replyTo) {
localStorage.removeItem('reply_to');
newMessageDiv.append(writeForm);
}
hideNewMessage(true);
};
const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : []; const tags = replyTo ? [['e', replyTo, eventRelayMap[replyTo][0]]] : [];
const newEvent = { const newEvent = await powEvent({
kind: 1, kind: 1,
pubkey,
content, content,
pubkey,
tags, tags,
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}; }, {difficulty, statusElem: sendStatus, timeout}).catch(console.warn);
if (!newEvent) {
close();
return;
}
const sig = await signEvent(newEvent, privatekey).catch(onSendError); const sig = await signEvent(newEvent, privatekey).catch(onSendError);
if (sig) { if (sig) {
sendStatus.textContent = 'publishing…';
const ev = await pool.publish({...newEvent, sig}, (status, url) => { const ev = await pool.publish({...newEvent, sig}, (status, url) => {
if (status === 0) { if (status === 0) {
console.info(`publish request sent to ${url}`); console.info(`publish request sent to ${url}`);
} }
if (status === 1) { if (status === 1) {
sendStatus.textContent = ''; close();
writeInput.value = '';
writeInput.style.removeProperty('height');
publish.disabled = true;
if (replyTo) {
localStorage.removeItem('reply_to');
newMessageDiv.append(writeForm);
}
hideNewMessage(true);
// console.info(`event published by ${url}`, ev); // console.info(`event published by ${url}`, ev);
} }
}); });
@ -934,15 +972,19 @@ profileForm.addEventListener('input', (e) => {
profileForm.addEventListener('submit', async (e) => { profileForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const form = new FormData(profileForm); const form = new FormData(profileForm);
const privatekey = localStorage.getItem('private_key'); const newProfile = await powEvent({
const newProfile = {
kind: 0, kind: 0,
pubkey, pubkey,
content: JSON.stringify(Object.fromEntries(form)), content: JSON.stringify(Object.fromEntries(form)),
created_at: Math.floor(Date.now() * 0.001),
tags: [], tags: [],
}; created_at: Math.floor(Date.now() * 0.001),
}, {difficulty, statusElem: profileStatus, timeout}).catch(console.warn);
if (!newProfile) {
profileStatus.textContent = 'publishing profile data canceled';
profileStatus.hidden = false;
return;
}
const privatekey = localStorage.getItem('private_key');
const sig = await signEvent(newProfile, privatekey).catch(console.error); const sig = await signEvent(newProfile, privatekey).catch(console.error);
if (sig) { if (sig) {
const ev = await pool.publish({...newProfile, sig}, (status, url) => { const ev = await pool.publish({...newProfile, sig}, (status, url) => {
@ -957,3 +999,107 @@ profileForm.addEventListener('submit', async (e) => {
}).catch(console.error); }).catch(console.error);
} }
}); });
const errorOverlay = document.querySelector('#errorOverlay');
function promptError(error, options = {}) {
const {onAgain, onCancel} = options;
lockScroll();
errorOverlay.replaceChildren(
elem('h1', {className: 'error-title'}, error),
elem('p', {}, 'something went wrong'),
elem('div', {className: 'buttons'}, [
onCancel ? elem('button', {data: {action: 'close'}}, 'close') : '',
onAgain ? elem('button', {data: {action: 'again'}}, 'try again') : '',
]),
);
const handleOverlayClick = (e) => {
const button = e.target.closest('button');
if (button) {
switch(button.dataset.action) {
case 'close':
onCancel();
break;
case 'again':
onAgain();
break;
}
errorOverlay.removeEventListener('click', handleOverlayClick);
errorOverlay.hidden = true;
unlockScroll();
}
};
errorOverlay.addEventListener('click', handleOverlayClick);
errorOverlay.hidden = false;
}
/**
* 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.
* a zero mining target just resolves the promise without trying to find a 'nonce'.
*/
function powEvent(evt, options) {
const {difficulty, statusElem, timeout} = options;
if (difficulty === 0) {
return Promise.resolve(evt);
}
const cancelBtn = elem('button', {className: 'btn-inline'}, [elem('small', {}, 'cancel')]);
statusElem.replaceChildren('working…', cancelBtn);
statusElem.hidden = false;
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js');
const onCancel = () => {
worker.terminate();
reject('canceled');
};
cancelBtn.addEventListener('click', onCancel);
worker.onmessage = (msg) => {
worker.terminate();
cancelBtn.removeEventListener('click', onCancel);
if (msg.data.error) {
promptError(msg.data.error, {
onCancel: () => reject('canceled'),
onAgain: async () => {
const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn);
resolve(result);
}
})
} else {
resolve(msg.data.event);
}
};
worker.onerror = (err) => {
worker.terminate();
cancelBtn.removeEventListener('click', onCancel);
reject(err);
};
worker.postMessage({event: evt, difficulty, timeout});
});
}

@ -43,9 +43,9 @@ input[type="radio"]:checked + label {
} }
.tab-content { .tab-content {
max-width: 96ch; max-width: var(--max-width);
min-height: 200px; min-height: 200px;
padding: calc(.5 * var(--gap)) 0 100px 0; padding: var(--gap-half) 0 100px 0;
} }
.tabbed { .tabbed {
align-items: start; align-items: start;

@ -0,0 +1,42 @@
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 until = Math.floor(Date.now() * 0.001) + timeout;
console.time('pow');
while (true) {
const now = Math.floor(Date.now() * 0.001);
if (timeout !== 0 && (now > until)) {
console.timeEnd('pow');
throw 'timeout';
}
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.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