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.js',
'src/manifest.json',
'src/worker.js',
],
outdir: 'dist',
//entryNames: '[name]-[hash]', TODO: replace urls in index.html with hashed paths

@ -10,7 +10,7 @@
}
@media (orientation: portrait) {
.mbox {
padding: 0 calc(.5 * var(--gap));
padding: 0 var(--gap-half);
}
}
.mbox:last-child {
@ -97,6 +97,16 @@
overflow: visible;
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 {
flex-grow: 1;
position: relative;
@ -158,6 +168,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;
};

@ -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);
}
input[type="number"],
input[type="password"],
input[type="text"],
input[type="url"],
@ -54,6 +55,7 @@ textarea {
margin: 0 0 1.2rem 0;
padding: var(--padding);
}
input[type="number"]:focus,
input[type="password"]:focus,
input[type="text"]:focus,
input[type="url"]:focus,
@ -95,14 +97,22 @@ textarea:focus {
align-items: center;
display: flex;
flex-basis: 100%;
justify-content: flex-end;
gap: var(--gap);
margin-top: 2rem;
justify-content: start;
margin-top: var(--gap-half);
min-height: 3.2rem;
}
form .buttons,
.form .buttons,
.form-inline .buttons {
flex-basis: fit-content;
margin-top: 0;
justify-content: end;
}
.buttons img,
.buttons small,
.buttons span {
vertical-align: middle;
}
button {
@ -121,24 +131,11 @@ button:focus {
.btn-inline {
--border-color: transparent;
align-items: center;
background: transparent;
color: var(--color);
display: inline-flex;
gap: .5ch;
color: var(--color-accent);
display: inline-block;
line-height: 1;
padding: .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;
padding: 0 .6rem;
}
.btn-danger {
@ -154,6 +151,7 @@ button:disabled {
.form-status {
flex-basis: 100%;
flex-grow: 1;
min-height: 1.8rem;
padding: var(--padding);
}
@ -169,11 +167,13 @@ button:disabled {
margin-left: var(--gap);
}
.form-inline button,
.form-inline input[type="number"],
.form-inline input[type="text"],
.form-inline textarea {
margin: .4rem 0;
}
.form-inline input[type="number"],
.form-inline input[type="text"],
.form-inline textarea {
flex-basis: 50%;
@ -187,6 +187,46 @@ button:disabled {
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 {
align-self: end;
order: 2;

@ -83,6 +83,27 @@
<button type="submit" name="publish" tabindex="0" disabled>publish</button>
</div>
</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">
<label for="pubkey">public-key</label>
<input type="text" id="pubkey" autocomplete="off">
@ -106,6 +127,7 @@
</footer>
</div>
</div>
<div id="errorOverlay" class="form" hidden></div>
</main>
</body>

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

@ -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/
@ -160,9 +161,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;
}
}
@ -216,7 +217,7 @@ document.body.addEventListener('click', (e) => {
writeInput.blur();
return;
}
appendReplyForm(button);
appendReplyForm(button.closest('.buttons'));
localStorage.setItem('reply_to', id);
return;
}
@ -439,28 +440,26 @@ 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',
data: {'eventId': evt.id, relay},
}, [
elem('img', {
alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24,
src: `assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionList(evt.id).join(' '),
}),
elem('small', {data: {reactions: evt.id}}, hasReactions ? reactionMap[evt.id].length : ''),
elem('div', {className: 'buttons'}, [
elem('button', {name: 'reply', type: 'button'}, [
elem('img', {height: 24, width: 24, src: 'assets/comment.svg'})
]),
elem('button', {name: 'star', type: 'button'}, [
elem('img', {
alt: didReact ? '✭' : '✩', // ♥
height: 24, width: 24,
src: `assets/${didReact ? 'star-fill' : 'star'}.svg`,
title: getReactionList(evt.id).join(' '),
}),
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()) : '',
]);
if (restoredReplyTo === evt.id) {
appendReplyForm(body.querySelector('button[name="reply"]'));
appendReplyForm(body.querySelector('.buttons'));
requestAnimationFrame(() => updateElemHeight(writeInput));
}
return renderArticle([
@ -479,7 +478,7 @@ function handleReply(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];
if (!article) { // root article has not been rendered
return;
@ -615,7 +614,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,14 +674,14 @@ 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,
title: `${userName} on ${host} ${userAbout}`,
}) : elemCanvas(evt.pubkey);
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);
return {host, img, isReply, name, replies, time, userName};
}
@ -724,6 +723,9 @@ function appendReplyForm(el) {
requestAnimationFrame(() => writeInput.focus());
}
const lockScroll = () => document.body.style.overflow = 'hidden';
const unlockScroll = () => document.body.style.removeProperty('overflow');
const newMessageDiv = document.querySelector('#newMessage');
document.querySelector('#bubble').addEventListener('click', (e) => {
localStorage.removeItem('reply_to'); // should it forget old replyto context?
@ -733,7 +735,7 @@ document.querySelector('#bubble').addEventListener('click', (e) => {
if (writeInput.value.trimRight()) {
writeInput.style.removeProperty('height');
}
document.body.style.overflow = 'hidden';
lockScroll();
requestAnimationFrame(() => updateElemHeight(writeInput));
});
@ -744,12 +746,28 @@ document.body.addEventListener('keyup', (e) => {
});
function hideNewMessage(hide) {
document.body.style.removeProperty('overflow');
unlockScroll();
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) {
const privatekey = localStorage.getItem('private_key');
const note = replyList.find(r => r.id === eventId) || textNoteList.find(n => n.id === (eventId));
const 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
['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,
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, 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);
if (sig) {
statusElem.textContent = 'publishing…';
const ev = await pool.publish({...newReaction, sig}, (status, url) => {
if (status === 0) {
console.info(`publish request sent to ${url}`);
@ -774,6 +803,7 @@ async function upvote(eventId, eventPubkey) {
console.info(`event published by ${url}`);
}
}).catch(console.error);
reactionBtn.disabled = false;
}
}
@ -793,30 +823,38 @@ writeForm.addEventListener('submit', async (e) => {
return onSendError(new Error('message is empty'));
}
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 newEvent = {
const newEvent = await powEvent({
kind: 1,
pubkey,
content,
pubkey,
tags,
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);
if (sig) {
sendStatus.textContent = 'publishing…';
const ev = await pool.publish({...newEvent, sig}, (status, url) => {
if (status === 0) {
console.info(`publish request sent to ${url}`);
}
if (status === 1) {
sendStatus.textContent = '';
writeInput.value = '';
writeInput.style.removeProperty('height');
publish.disabled = true;
if (replyTo) {
localStorage.removeItem('reply_to');
newMessageDiv.append(writeForm);
}
hideNewMessage(true);
close();
// console.info(`event published by ${url}`, ev);
}
});
@ -934,15 +972,19 @@ profileForm.addEventListener('input', (e) => {
profileForm.addEventListener('submit', async (e) => {
e.preventDefault();
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, 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);
if (sig) {
const ev = await pool.publish({...newProfile, sig}, (status, url) => {
@ -957,3 +999,107 @@ profileForm.addEventListener('submit', async (e) => {
}).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 {
max-width: 96ch;
max-width: var(--max-width);
min-height: 200px;
padding: calc(.5 * var(--gap)) 0 100px 0;
padding: var(--gap-half) 0 100px 0;
}
.tabbed {
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