mirror of
https://github.com/smolgrrr/TAO.git
synced 2024-09-20 09:21:25 +00:00
add a bunch of random shit from nostr.ch
This commit is contained in:
parent
9f29a2bdbc
commit
7dd6769a36
@ -4,6 +4,8 @@ import PostCard from './PostCard/PostCard';
|
|||||||
import Header from './Header/Header';
|
import Header from './Header/Header';
|
||||||
import NewThreadCard from './PostCard/NewThreadCard';
|
import NewThreadCard from './PostCard/NewThreadCard';
|
||||||
|
|
||||||
|
let filterDifficulty: number = 1;
|
||||||
|
|
||||||
// Define the Event interface
|
// Define the Event interface
|
||||||
interface Event {
|
interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
@ -21,17 +23,47 @@ const Home = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
relay.on('connect', async () => {
|
relay.on('connect', async () => {
|
||||||
console.log(`connected to ${relay.url}`);
|
console.log(`connected to ${relay.url}`);
|
||||||
|
console.log(Math.floor(Date.now() - (30 * 24 * 60 * 60 * 1000)))
|
||||||
|
|
||||||
|
// Pad the ID with leading zeros based on filterDifficulty
|
||||||
|
const paddedId = "0".padStart(filterDifficulty + 1, '0');
|
||||||
|
console.log(paddedId);
|
||||||
|
|
||||||
const eventList = await relay.list([
|
const eventList = await relay.list([
|
||||||
{
|
{
|
||||||
ids: ['0000'],
|
ids: ['00'], //prefix number of leading zeros from filterDifficulty
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
|
//until: Date.now(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
//since: Math.floor(Date.now() - (30 * 24 * 60 * 60 * 1000)), // 24 hours ago
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
// const socket = new WebSocket('wss://relay.damus.io');
|
||||||
|
// socket.onopen = () => {
|
||||||
|
// console.log('WebSocket connected');
|
||||||
|
// const subscription = [
|
||||||
|
// 'REQ',
|
||||||
|
// 'POW-TEST',
|
||||||
|
// {
|
||||||
|
// ids: ["0000"],
|
||||||
|
// limit: 10,
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
// socket.send(JSON.stringify(subscription));
|
||||||
|
// };
|
||||||
|
|
||||||
|
// let i = 0;
|
||||||
|
// let start = Date.now();
|
||||||
|
// socket.onmessage = event => {
|
||||||
|
// console.log(event.data);
|
||||||
|
// };
|
||||||
|
// socket.onerror = error => {
|
||||||
|
// console.error('WebSocket error:', error);
|
||||||
|
// };
|
||||||
|
|
||||||
// Assuming eventList is of type Event[]
|
// Assuming eventList is of type Event[]
|
||||||
setEvents(eventList);
|
setEvents(eventList);
|
||||||
|
console.log(eventList);
|
||||||
});
|
});
|
||||||
|
|
||||||
relay.on('error', () => {
|
relay.on('error', () => {
|
||||||
@ -47,7 +79,7 @@ const Home = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
|
||||||
<NewThreadCard />
|
<NewThreadCard />
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => (
|
||||||
<PostCard key={index} content={event.content} />
|
<PostCard key={index} content={event.content} time={new Date(event.created_at * 1000)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
import CardContainer from './CardContainer';
|
import CardContainer from './CardContainer';
|
||||||
|
|
||||||
const PostCard = ({ content }: { content: string }) => {
|
interface PostCardProps {
|
||||||
|
content: string;
|
||||||
|
time: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostCard = ({ content, time }: PostCardProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardContainer>
|
<CardContainer>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
{time.toString()}
|
||||||
<div className="mr-2 flex flex-col break-words">
|
<div className="mr-2 flex flex-col break-words">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
4
client/src/constants/settings.ts
Normal file
4
client/src/constants/settings.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
let filterDifficulty: number = 0;
|
||||||
|
let difficulty: number = 16;
|
||||||
|
let timeout: number = 5;
|
||||||
|
|
64
client/src/func/events.ts
Normal file
64
client/src/func/events.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {Event} from 'nostr-tools';
|
||||||
|
import {zeroLeadingBitsCount} from '../utils/crypto';
|
||||||
|
|
||||||
|
export const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
|
||||||
|
export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention';
|
||||||
|
export const isPTag = ([tag]: string[]) => tag === 'p';
|
||||||
|
export const hasEventTag = (tag: string[]) => tag[0] === 'e';
|
||||||
|
export const isNotNonceTag = ([tag]: string[]) => tag !== 'nonce';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export const validatePow = (evt: Event) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortByCreatedAt = (evt1: Event, evt2: Event) => {
|
||||||
|
if (evt1.created_at === evt2.created_at) {
|
||||||
|
// console.log('TODO: OMG exactly at the same time, figure out how to sort then', evt1, evt2);
|
||||||
|
}
|
||||||
|
return evt1.created_at > evt2.created_at ? -1 : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortEventCreatedAt = (created_at: number) => (
|
||||||
|
{created_at: a}: Event,
|
||||||
|
{created_at: b}: Event,
|
||||||
|
) => (
|
||||||
|
Math.abs(a - created_at) < Math.abs(b - created_at) ? -1 : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
const isReply = ([tag, , , marker]: string[]) => tag === 'e' && marker !== 'mention';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* find reply-to ID according to nip-10, find marked reply or root tag or
|
||||||
|
* fallback to positional (last) e tag or return null
|
||||||
|
* @param {event} evt
|
||||||
|
* @returns replyToID | null
|
||||||
|
*/
|
||||||
|
export const getReplyTo = (evt: Event): string | null => {
|
||||||
|
const eventTags = evt.tags.filter(isReply);
|
||||||
|
const withReplyMarker = eventTags.filter(([, , , marker]) => marker === 'reply');
|
||||||
|
if (withReplyMarker.length === 1) {
|
||||||
|
return withReplyMarker[0][1];
|
||||||
|
}
|
||||||
|
const withRootMarker = eventTags.filter(([, , , marker]) => marker === 'root');
|
||||||
|
if (withReplyMarker.length === 0 && withRootMarker.length === 1) {
|
||||||
|
return withRootMarker[0][1];
|
||||||
|
}
|
||||||
|
// fallback to deprecated positional 'e' tags (nip-10)
|
||||||
|
const lastTag = eventTags.at(-1);
|
||||||
|
return lastTag ? lastTag[1] : null;
|
||||||
|
};
|
188
client/src/func/main.ts
Normal file
188
client/src/func/main.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import {Event, nip19} from 'nostr-tools';
|
||||||
|
import {zeroLeadingBitsCount} from './utils/crypto';
|
||||||
|
import {elem} from './utils/dom';
|
||||||
|
import {bounce} from './utils/time';
|
||||||
|
import {isWssUrl} from './utils/url';
|
||||||
|
import {closeSettingsView, config, toggleSettingsView} from './settings';
|
||||||
|
import {subGlobalFeed, subEventID, subNote} from './subscriptions'
|
||||||
|
import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
|
||||||
|
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
|
||||||
|
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
|
||||||
|
import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
|
||||||
|
|
||||||
|
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
|
||||||
|
|
||||||
|
type EventRelayMap = {
|
||||||
|
[eventId: string]: string[];
|
||||||
|
};
|
||||||
|
const eventRelayMap: EventRelayMap = {}; // eventId: [relay1, relay2]
|
||||||
|
|
||||||
|
const renderNote = (
|
||||||
|
evt: EventWithNip19,
|
||||||
|
i: number,
|
||||||
|
sortedFeeds: EventWithNip19[],
|
||||||
|
) => {
|
||||||
|
if (getViewElem(evt.id)) { // note already in view
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const article = createTextNote(evt, eventRelayMap[evt.id][0]);
|
||||||
|
if (i === 0) {
|
||||||
|
getViewContent().append(article);
|
||||||
|
} else {
|
||||||
|
getViewElem(sortedFeeds[i - 1].id).before(article);
|
||||||
|
}
|
||||||
|
setViewElem(evt.id, article);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEnoughPOW = (
|
||||||
|
[tag, , commitment]: string[],
|
||||||
|
eventId: string
|
||||||
|
) => {
|
||||||
|
return tag === 'nonce' && Number(commitment) >= config.filterDifficulty && zeroLeadingBitsCount(eventId) >= config.filterDifficulty;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFeed = bounce(() => {
|
||||||
|
const view = getViewOptions();
|
||||||
|
switch (view.type) {
|
||||||
|
case 'note':
|
||||||
|
textNoteList
|
||||||
|
.concat(replyList) // search id in notes and replies
|
||||||
|
.filter(note => note.id === view.id)
|
||||||
|
.forEach(renderNote);
|
||||||
|
break;
|
||||||
|
case 'feed':
|
||||||
|
const now = Math.floor(Date.now() * 0.001);
|
||||||
|
textNoteList
|
||||||
|
.filter(note => {
|
||||||
|
// dont render notes from the future
|
||||||
|
if (note.created_at > now) return false;
|
||||||
|
// if difficulty filter is configured dont render notes with too little pow
|
||||||
|
return !config.filterDifficulty || note.tags.some(tag => hasEnoughPOW(tag, note.id))
|
||||||
|
})
|
||||||
|
.sort(sortByCreatedAt)
|
||||||
|
.reverse()
|
||||||
|
.forEach(renderNote);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s)
|
||||||
|
|
||||||
|
const renderReply = (evt: EventWithNip19AndReplyTo) => {
|
||||||
|
const parent = getViewElem(evt.replyTo);
|
||||||
|
if (!parent || getViewElem(evt.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let replyContainer = parent.querySelector('.mbox-replies');
|
||||||
|
if (!replyContainer) {
|
||||||
|
replyContainer = elem('div', {className: 'mbox-replies'});
|
||||||
|
parent.append(replyContainer);
|
||||||
|
parent.classList.add('mbox-has-replies');
|
||||||
|
}
|
||||||
|
const reply = createTextNote(evt, eventRelayMap[evt.id][0]);
|
||||||
|
replyContainer.append(reply);
|
||||||
|
setViewElem(evt.id, reply);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReply = (evt: EventWithNip19, relay: string) => {
|
||||||
|
if (
|
||||||
|
getViewElem(evt.id) // already rendered probably received from another relay
|
||||||
|
|| evt.tags.some(isMention) // ignore mentions for now
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const replyTo = getReplyTo(evt);
|
||||||
|
if (!replyTo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const evtWithReplyTo = {replyTo, ...evt};
|
||||||
|
replyList.push(evtWithReplyTo);
|
||||||
|
renderReply(evtWithReplyTo);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextNote = (evt: Event, relay: string) => {
|
||||||
|
if (evt.content.startsWith('vmess://') && !evt.content.includes(' ')) {
|
||||||
|
console.info('drop VMESS encrypted message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (eventRelayMap[evt.id]) {
|
||||||
|
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: remove eventRelayMap and just check for getViewElem?
|
||||||
|
} else {
|
||||||
|
eventRelayMap[evt.id] = [relay];
|
||||||
|
const evtWithNip19 = {
|
||||||
|
nip19: {
|
||||||
|
note: nip19.noteEncode(evt.id),
|
||||||
|
npub: nip19.npubEncode(evt.pubkey),
|
||||||
|
},
|
||||||
|
...evt,
|
||||||
|
};
|
||||||
|
if (evt.tags.some(hasEventTag)) {
|
||||||
|
handleReply(evtWithNip19, relay);
|
||||||
|
} else {
|
||||||
|
textNoteList.push(evtWithNip19);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!getViewElem(evt.id)) {
|
||||||
|
renderFeed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerenderFeed = () => {
|
||||||
|
clearView();
|
||||||
|
renderFeed();
|
||||||
|
};
|
||||||
|
config.rerenderFeed = rerenderFeed;
|
||||||
|
|
||||||
|
const onEvent = (evt: Event, relay: string) => {
|
||||||
|
switch (evt.kind) {
|
||||||
|
case 0:
|
||||||
|
handleTextNote(evt, relay);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// console.log(`TODO: add support for event kind ${evt.kind}`/*, evt*/)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// subscribe and change view
|
||||||
|
const route = (path: string) => {
|
||||||
|
if (path === '/') {
|
||||||
|
subGlobalFeed(onEvent);
|
||||||
|
view('/feed', {type: 'feed'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === '/feed') {
|
||||||
|
subGlobalFeed(onEvent);
|
||||||
|
view('/feed', {type: 'feed'});
|
||||||
|
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
|
||||||
|
const {type, data} = nip19.decode(path.slice(1));
|
||||||
|
if (typeof data !== 'string') {
|
||||||
|
console.warn('nip19 ProfilePointer, EventPointer and AddressPointer are not yet supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch(type) {
|
||||||
|
case 'note':
|
||||||
|
subNote(data, onEvent);
|
||||||
|
view(path, {type: 'note', id: data});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`type ${type} not yet supported`);
|
||||||
|
}
|
||||||
|
renderFeed();
|
||||||
|
} else if (path.length === 65) {
|
||||||
|
const eventID = path.slice(1);
|
||||||
|
subEventID(eventID, onEventDetails);
|
||||||
|
view(path, {type: 'event', id: eventID});
|
||||||
|
} else {
|
||||||
|
console.warn('no support for ', path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// onload
|
||||||
|
route(location.pathname);
|
||||||
|
|
||||||
|
// only push a new entry if there is no history onload
|
||||||
|
if (!history.length) {
|
||||||
|
history.pushState({}, '', location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', (event) => {
|
||||||
|
route(location.pathname);
|
||||||
|
});
|
111
client/src/func/relays.ts
Normal file
111
client/src/func/relays.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import {Event, Filter, relayInit, Relay, Sub} from 'nostr-tools';
|
||||||
|
|
||||||
|
type SubCallback = (
|
||||||
|
event: Readonly<Event>,
|
||||||
|
relay: Readonly<string>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type Subscribe = {
|
||||||
|
cb: SubCallback;
|
||||||
|
filter: Filter;
|
||||||
|
unsub?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subList: Array<Sub> = [];
|
||||||
|
const currentSubList: Array<Subscribe> = [];
|
||||||
|
const relayMap = new Map<string, Relay>();
|
||||||
|
|
||||||
|
export const addRelay = async (url: string) => {
|
||||||
|
const relay = relayInit(url);
|
||||||
|
relay.on('connect', () => {
|
||||||
|
console.info(`connected to ${relay.url}`);
|
||||||
|
});
|
||||||
|
relay.on('error', () => {
|
||||||
|
console.warn(`failed to connect to ${relay.url}`);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await relay.connect();
|
||||||
|
currentSubList.forEach(({cb, filter}) => subscribe(cb, filter, relay));
|
||||||
|
relayMap.set(url, relay);
|
||||||
|
} catch {
|
||||||
|
console.warn(`could not connect to ${url}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsubscribe = (sub: Sub) => {
|
||||||
|
sub.unsub();
|
||||||
|
subList.splice(subList.indexOf(sub), 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribe = (
|
||||||
|
cb: SubCallback,
|
||||||
|
filter: Filter,
|
||||||
|
relay: Relay,
|
||||||
|
unsub?: boolean
|
||||||
|
) => {
|
||||||
|
const sub = relay.sub([filter]);
|
||||||
|
subList.push(sub);
|
||||||
|
sub.on('event', (event: Event) => {
|
||||||
|
cb(event, relay.url);
|
||||||
|
});
|
||||||
|
if (unsub) {
|
||||||
|
sub.on('eose', () => {
|
||||||
|
// console.log('eose', relay.url);
|
||||||
|
unsubscribe(sub);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sub;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sub = (obj: Subscribe) => {
|
||||||
|
currentSubList.push(obj);
|
||||||
|
relayMap.forEach((relay) => subscribe(obj.cb, obj.filter, relay, obj.unsub));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subOnce = (
|
||||||
|
obj: Subscribe & {relay: string}
|
||||||
|
) => {
|
||||||
|
const relay = relayMap.get(obj.relay);
|
||||||
|
if (relay) {
|
||||||
|
const sub = subscribe(obj.cb, obj.filter, relay);
|
||||||
|
sub.on('eose', () => {
|
||||||
|
// console.log('eose', obj.relay);
|
||||||
|
unsubscribe(sub);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsubAll = () => {
|
||||||
|
subList.forEach(unsubscribe);
|
||||||
|
currentSubList.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PublishCallback = (
|
||||||
|
relay: string,
|
||||||
|
errorMessage?: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export const publish = (
|
||||||
|
event: Event,
|
||||||
|
cb: PublishCallback,
|
||||||
|
) => {
|
||||||
|
relayMap.forEach((relay, url) => {
|
||||||
|
const pub = relay.publish(event);
|
||||||
|
pub.on('ok', () => {
|
||||||
|
console.info(`${relay.url} has accepted our event`);
|
||||||
|
cb(relay.url);
|
||||||
|
});
|
||||||
|
pub.on('failed', (reason: any) => {
|
||||||
|
console.error(`failed to publish to ${relay.url}: ${reason}`);
|
||||||
|
cb(relay.url, reason);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
addRelay('wss://relay.snort.social');
|
||||||
|
addRelay('wss://nostr.bitcoiner.social');
|
||||||
|
addRelay('wss://nostr.mom');
|
||||||
|
addRelay('wss://relay.nostr.bg');
|
||||||
|
addRelay('wss://nos.lol');
|
||||||
|
// addRelay('wss://relay.nostr.ch');
|
236
client/src/func/settings.ts
Normal file
236
client/src/func/settings.ts
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import {generatePrivateKey, getPublicKey, signEvent} from 'nostr-tools';
|
||||||
|
import {updateElemHeight} from './utils/dom';
|
||||||
|
import {powEvent} from './system';
|
||||||
|
import {publish} from './relays';
|
||||||
|
|
||||||
|
const settingsView = document.querySelector('#settings') as HTMLElement;
|
||||||
|
|
||||||
|
export const closeSettingsView = () => settingsView.hidden = true;
|
||||||
|
|
||||||
|
export const toggleSettingsView = () => settingsView.hidden = !settingsView.hidden;
|
||||||
|
|
||||||
|
let pubkey: string = '';
|
||||||
|
|
||||||
|
const loadOrGenerateKeys = () => {
|
||||||
|
const storedPubKey = localStorage.getItem('pub_key');
|
||||||
|
if (storedPubKey) {
|
||||||
|
return storedPubKey;
|
||||||
|
}
|
||||||
|
const privatekey = generatePrivateKey();
|
||||||
|
const pubkey = getPublicKey(privatekey);
|
||||||
|
localStorage.setItem('private_key', privatekey);
|
||||||
|
localStorage.setItem('pub_key', pubkey);
|
||||||
|
return pubkey;
|
||||||
|
};
|
||||||
|
|
||||||
|
let filterDifficulty: number = 0;
|
||||||
|
let difficulty: number = 16;
|
||||||
|
let timeout: number = 5;
|
||||||
|
let rerenderFeed: (() => void) | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* global config object
|
||||||
|
* config.pubkey, if not set loaded from localStorage or generate a new key
|
||||||
|
*/
|
||||||
|
export const config = {
|
||||||
|
get pubkey() {
|
||||||
|
if (!pubkey) {
|
||||||
|
pubkey = loadOrGenerateKeys();
|
||||||
|
}
|
||||||
|
return pubkey;
|
||||||
|
},
|
||||||
|
set pubkey(value) {
|
||||||
|
console.info(`pubkey was set to ${value}`);
|
||||||
|
pubkey = value;
|
||||||
|
},
|
||||||
|
get filterDifficulty() {
|
||||||
|
return filterDifficulty;
|
||||||
|
},
|
||||||
|
get difficulty() {
|
||||||
|
return difficulty;
|
||||||
|
},
|
||||||
|
get timeout() {
|
||||||
|
return timeout;
|
||||||
|
},
|
||||||
|
set rerenderFeed(value: () => void) {
|
||||||
|
rerenderFeed = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumberFromStorage = (
|
||||||
|
item: string,
|
||||||
|
fallback: number,
|
||||||
|
) => {
|
||||||
|
const stored = localStorage.getItem(item);
|
||||||
|
if (!stored) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Number(stored);
|
||||||
|
};
|
||||||
|
|
||||||
|
// filter difficulty
|
||||||
|
const filterDifficultyInput = document.querySelector('#filterDifficulty') as HTMLInputElement;
|
||||||
|
const filterDifficultyDisplay = document.querySelector('[data-display="filter_difficulty"]') as HTMLElement;
|
||||||
|
filterDifficultyInput.addEventListener('input', (e) => {
|
||||||
|
localStorage.setItem('filter_difficulty', filterDifficultyInput.value);
|
||||||
|
filterDifficulty = filterDifficultyInput.valueAsNumber;
|
||||||
|
filterDifficultyDisplay.textContent = filterDifficultyInput.value;
|
||||||
|
rerenderFeed && rerenderFeed();
|
||||||
|
});
|
||||||
|
filterDifficulty = getNumberFromStorage('filter_difficulty', 0);
|
||||||
|
filterDifficultyInput.valueAsNumber = filterDifficulty;
|
||||||
|
filterDifficultyDisplay.textContent = filterDifficultyInput.value;
|
||||||
|
|
||||||
|
// mining difficulty target
|
||||||
|
const miningTargetInput = document.querySelector('#miningTarget') as HTMLInputElement;
|
||||||
|
miningTargetInput.addEventListener('input', (e) => {
|
||||||
|
localStorage.setItem('mining_target', miningTargetInput.value);
|
||||||
|
difficulty = miningTargetInput.valueAsNumber;
|
||||||
|
});
|
||||||
|
// arbitrary difficulty default, still experimenting.
|
||||||
|
difficulty = getNumberFromStorage('mining_target', 16);
|
||||||
|
miningTargetInput.valueAsNumber = difficulty;
|
||||||
|
|
||||||
|
// mining timeout
|
||||||
|
const miningTimeoutInput = document.querySelector('#miningTimeout') as HTMLInputElement;
|
||||||
|
miningTimeoutInput.addEventListener('input', (e) => {
|
||||||
|
localStorage.setItem('mining_timeout', miningTimeoutInput.value);
|
||||||
|
timeout = miningTimeoutInput.valueAsNumber;
|
||||||
|
});
|
||||||
|
timeout = getNumberFromStorage('mining_timeout', 5);
|
||||||
|
miningTimeoutInput.valueAsNumber = timeout;
|
||||||
|
|
||||||
|
|
||||||
|
// settings
|
||||||
|
const settingsForm = document.querySelector('form[name="settings"]') as HTMLFormElement;
|
||||||
|
const privateKeyInput = settingsForm.querySelector('#privatekey') as HTMLInputElement;
|
||||||
|
const pubKeyInput = settingsForm.querySelector('#pubkey') as HTMLInputElement;
|
||||||
|
const statusMessage = settingsForm.querySelector('#keystatus') as HTMLElement;
|
||||||
|
const generateBtn = settingsForm.querySelector('button[name="generate"]') as HTMLButtonElement;
|
||||||
|
const importBtn = settingsForm.querySelector('button[name="import"]') as HTMLButtonElement;
|
||||||
|
const privateTgl = settingsForm.querySelector('button[name="privatekey-toggle"]') as HTMLButtonElement;
|
||||||
|
|
||||||
|
const validKeys = (
|
||||||
|
privatekey: string,
|
||||||
|
pubkey: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (getPublicKey(privatekey) === pubkey) {
|
||||||
|
statusMessage.hidden = true;
|
||||||
|
statusMessage.textContent = 'public-key corresponds to private-key';
|
||||||
|
importBtn.removeAttribute('disabled');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
statusMessage.textContent = 'private-key does not correspond to public-key!'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusMessage.textContent = `not a valid private-key: ${e.message || e}`;
|
||||||
|
}
|
||||||
|
statusMessage.hidden = false;
|
||||||
|
importBtn.disabled = true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
generateBtn.addEventListener('click', () => {
|
||||||
|
const privatekey = generatePrivateKey();
|
||||||
|
const pubkey = getPublicKey(privatekey);
|
||||||
|
if (validKeys(privatekey, pubkey)) {
|
||||||
|
privateKeyInput.value = privatekey;
|
||||||
|
pubKeyInput.value = pubkey;
|
||||||
|
statusMessage.textContent = 'private-key created!';
|
||||||
|
statusMessage.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
importBtn.addEventListener('click', () => {
|
||||||
|
const privatekey = privateKeyInput.value;
|
||||||
|
const pubkeyInput = pubKeyInput.value;
|
||||||
|
if (validKeys(privatekey, pubkeyInput)) {
|
||||||
|
localStorage.setItem('private_key', privatekey);
|
||||||
|
localStorage.setItem('pub_key', pubkeyInput);
|
||||||
|
statusMessage.textContent = 'stored private and public key locally!';
|
||||||
|
statusMessage.hidden = false;
|
||||||
|
config.pubkey = pubkeyInput;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsForm.addEventListener('input', () => validKeys(privateKeyInput.value, pubKeyInput.value));
|
||||||
|
|
||||||
|
privateKeyInput.addEventListener('paste', (event) => {
|
||||||
|
if (pubKeyInput.value || !event.clipboardData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (privateKeyInput.value === '' || ( // either privatekey field is empty
|
||||||
|
privateKeyInput.selectionStart === 0 // or the whole text is selected and replaced with the clipboard
|
||||||
|
&& privateKeyInput.selectionEnd === privateKeyInput.value.length
|
||||||
|
)) { // only generate the pubkey if no data other than the text from clipboard will be used
|
||||||
|
try {
|
||||||
|
pubKeyInput.value = getPublicKey(event.clipboardData.getData('text'));
|
||||||
|
} catch(err) {} // settings form will call validKeys on input and display the error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
privateTgl.addEventListener('click', () => {
|
||||||
|
privateKeyInput.type = privateKeyInput.type === 'text' ? 'password' : 'text';
|
||||||
|
});
|
||||||
|
|
||||||
|
privateKeyInput.value = localStorage.getItem('private_key') || '';
|
||||||
|
pubKeyInput.value = localStorage.getItem('pub_key') || '';
|
||||||
|
|
||||||
|
// profile
|
||||||
|
const profileForm = document.querySelector('form[name="profile"]') as HTMLFormElement;
|
||||||
|
const profileSubmit = profileForm.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||||
|
const profileStatus = document.querySelector('#profilestatus') as HTMLElement;
|
||||||
|
|
||||||
|
profileForm.addEventListener('input', (e) => {
|
||||||
|
if (e.target instanceof HTMLElement) {
|
||||||
|
if (e.target?.nodeName === 'TEXTAREA') {
|
||||||
|
updateElemHeight(e.target as HTMLTextAreaElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const form = new FormData(profileForm);
|
||||||
|
const name = form.get('name');
|
||||||
|
const about = form.get('about');
|
||||||
|
const picture = form.get('picture');
|
||||||
|
profileSubmit.disabled = !(name || about || picture);
|
||||||
|
});
|
||||||
|
|
||||||
|
profileForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = new FormData(profileForm);
|
||||||
|
const newProfile = await powEvent({
|
||||||
|
kind: 0,
|
||||||
|
pubkey: config.pubkey,
|
||||||
|
content: JSON.stringify(Object.fromEntries(form)),
|
||||||
|
tags: [],
|
||||||
|
created_at: Math.floor(Date.now() * 0.001)
|
||||||
|
}, {
|
||||||
|
difficulty: config.difficulty,
|
||||||
|
statusElem: profileStatus,
|
||||||
|
timeout: config.timeout,
|
||||||
|
}).catch(console.warn);
|
||||||
|
if (!newProfile) {
|
||||||
|
profileStatus.textContent = 'publishing profile data canceled';
|
||||||
|
profileStatus.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const privatekey = localStorage.getItem('private_key');
|
||||||
|
if (!privatekey) {
|
||||||
|
profileStatus.textContent = 'no private key to sign';
|
||||||
|
profileStatus.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sig = signEvent(newProfile, privatekey);
|
||||||
|
// TODO: validateEvent
|
||||||
|
if (sig) {
|
||||||
|
publish({...newProfile, sig}, (relay, error) => {
|
||||||
|
if (error) {
|
||||||
|
return console.error(error, relay);
|
||||||
|
}
|
||||||
|
console.info(`publish request sent to ${relay}`);
|
||||||
|
profileStatus.textContent = 'profile successfully published';
|
||||||
|
profileStatus.hidden = false;
|
||||||
|
profileSubmit.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
136
client/src/func/subscriptions.ts
Normal file
136
client/src/func/subscriptions.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import {Event} from 'nostr-tools';
|
||||||
|
import {getReplyTo, hasEventTag, isMention, isPTag} from './events';
|
||||||
|
import {config} from './settings';
|
||||||
|
import {sub, subOnce, unsubAll} from './relays';
|
||||||
|
|
||||||
|
type SubCallback = (
|
||||||
|
event: Event,
|
||||||
|
relay: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/** subscribe to global feed */
|
||||||
|
export const subGlobalFeed = (onEvent: SubCallback) => {
|
||||||
|
console.info('subscribe to global feed');
|
||||||
|
unsubAll();
|
||||||
|
const now = Math.floor(Date.now() * 0.001);
|
||||||
|
const pubkeys = new Set<string>();
|
||||||
|
const notes = new Set<string>();
|
||||||
|
const prefix = Math.floor(config.filterDifficulty / 4); // 4 bits in each '0' character
|
||||||
|
sub({ // get past events
|
||||||
|
cb: (evt, relay) => {
|
||||||
|
pubkeys.add(evt.pubkey);
|
||||||
|
notes.add(evt.id);
|
||||||
|
onEvent(evt, relay);
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
...(prefix && {ids: ['0'.repeat(prefix)]}),
|
||||||
|
kinds: [1],
|
||||||
|
until: now,
|
||||||
|
...(!prefix && {since: Math.floor(now - (24 * 60 * 60))}),
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
unsub: true
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// get profile info
|
||||||
|
sub({
|
||||||
|
cb: onEvent,
|
||||||
|
filter: {
|
||||||
|
authors: Array.from(pubkeys),
|
||||||
|
kinds: [0],
|
||||||
|
limit: pubkeys.size,
|
||||||
|
},
|
||||||
|
unsub: true,
|
||||||
|
});
|
||||||
|
pubkeys.clear();
|
||||||
|
|
||||||
|
notes.clear();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// subscribe to future notes, reactions and profile updates
|
||||||
|
sub({
|
||||||
|
cb: (evt, relay) => {
|
||||||
|
onEvent(evt, relay);
|
||||||
|
if (
|
||||||
|
evt.kind !== 1
|
||||||
|
|| pubkeys.has(evt.pubkey)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
...(prefix && {ids: ['0'.repeat(prefix)]}),
|
||||||
|
kinds: [1],
|
||||||
|
since: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** subscribe to a note id (nip-19) */
|
||||||
|
export const subNote = (
|
||||||
|
eventId: string,
|
||||||
|
onEvent: SubCallback,
|
||||||
|
) => {
|
||||||
|
unsubAll();
|
||||||
|
sub({
|
||||||
|
cb: onEvent,
|
||||||
|
filter: {
|
||||||
|
ids: [eventId],
|
||||||
|
kinds: [1],
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
unsub: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const replies = new Set<string>();
|
||||||
|
|
||||||
|
const onReply = (evt: Event, relay: string) => {
|
||||||
|
replies.add(evt.id)
|
||||||
|
onEvent(evt, relay);
|
||||||
|
unsubAll();
|
||||||
|
sub({
|
||||||
|
cb: onEvent,
|
||||||
|
filter: {
|
||||||
|
'#e': Array.from(replies),
|
||||||
|
kinds: [1, 7],
|
||||||
|
},
|
||||||
|
unsub: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
replies.add(eventId);
|
||||||
|
setTimeout(() => {
|
||||||
|
sub({
|
||||||
|
cb: onReply,
|
||||||
|
filter: {
|
||||||
|
'#e': [eventId],
|
||||||
|
kinds: [1, 7],
|
||||||
|
},
|
||||||
|
unsub: true, // TODO: probably keep this subscription also after onReply/unsubAll
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subEventID = (
|
||||||
|
id: string,
|
||||||
|
onEvent: SubCallback,
|
||||||
|
) => {
|
||||||
|
unsubAll();
|
||||||
|
sub({
|
||||||
|
cb: onEvent,
|
||||||
|
filter: {
|
||||||
|
ids: [id],
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
unsub: true,
|
||||||
|
});
|
||||||
|
sub({
|
||||||
|
cb: onEvent,
|
||||||
|
filter: {
|
||||||
|
authors: [id],
|
||||||
|
limit: 200,
|
||||||
|
},
|
||||||
|
unsub: true,
|
||||||
|
});
|
||||||
|
};
|
124
client/src/func/system.ts
Normal file
124
client/src/func/system.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import {Event, getEventHash, UnsignedEvent} from 'nostr-tools';
|
||||||
|
import {elem, lockScroll, unlockScroll} from './utils/dom';
|
||||||
|
|
||||||
|
const errorOverlay = document.querySelector('section#errorOverlay') as HTMLElement;
|
||||||
|
|
||||||
|
type PromptErrorOptions = {
|
||||||
|
onCancel?: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an error overlay, currently with hardcoded POW related message, this could be come a generic prompt
|
||||||
|
* @param error message
|
||||||
|
* @param options {onRetry, onCancel} callbacks
|
||||||
|
*/
|
||||||
|
const promptError = (
|
||||||
|
error: string,
|
||||||
|
options: PromptErrorOptions,
|
||||||
|
) => {
|
||||||
|
const {onCancel, onRetry} = options;
|
||||||
|
lockScroll();
|
||||||
|
errorOverlay.replaceChildren(
|
||||||
|
elem('h1', {className: 'error-title'}, error),
|
||||||
|
elem('p', {}, 'time ran out finding a proof with the desired mining difficulty. either try again, lower the mining difficulty or increase the timeout in profile settings.'),
|
||||||
|
elem('div', {className: 'buttons'}, [
|
||||||
|
onCancel ? elem('button', {data: {action: 'close'}}, 'close') : '',
|
||||||
|
onRetry ? elem('button', {data: {action: 'again'}}, 'try again') : '',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const handleOverlayClick = (e: MouseEvent) => {
|
||||||
|
if (e.target instanceof Element) {
|
||||||
|
const button = e.target.closest('button');
|
||||||
|
if (button) {
|
||||||
|
switch(button.dataset.action) {
|
||||||
|
case 'close':
|
||||||
|
onCancel && onCancel();
|
||||||
|
break;
|
||||||
|
case 'again':
|
||||||
|
onRetry && onRetry();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
errorOverlay.removeEventListener('click', handleOverlayClick);
|
||||||
|
errorOverlay.hidden = true;
|
||||||
|
unlockScroll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
errorOverlay.addEventListener('click', handleOverlayClick);
|
||||||
|
errorOverlay.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PowEventOptions = {
|
||||||
|
difficulty: number;
|
||||||
|
statusElem: HTMLElement;
|
||||||
|
timeout: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkerResponse = {
|
||||||
|
error: string;
|
||||||
|
event: Event;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HashedEvent = UnsignedEvent & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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'.
|
||||||
|
*/
|
||||||
|
export const powEvent = (
|
||||||
|
evt: UnsignedEvent,
|
||||||
|
options: PowEventOptions
|
||||||
|
): Promise<HashedEvent | void> => {
|
||||||
|
const {difficulty, statusElem, timeout} = options;
|
||||||
|
if (difficulty === 0) {
|
||||||
|
return Promise.resolve({
|
||||||
|
...evt,
|
||||||
|
id: getEventHash(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(`mining kind ${evt.kind} event canceled`);
|
||||||
|
};
|
||||||
|
cancelBtn.addEventListener('click', onCancel);
|
||||||
|
|
||||||
|
worker.onmessage = (msg: MessageEvent<WorkerResponse>) => {
|
||||||
|
worker.terminate();
|
||||||
|
cancelBtn.removeEventListener('click', onCancel);
|
||||||
|
if (msg.data.error) {
|
||||||
|
promptError(msg.data.error, {
|
||||||
|
onCancel: () => reject(`mining kind ${evt.kind} event canceled`),
|
||||||
|
onRetry: async () => {
|
||||||
|
const result = await powEvent(evt, {difficulty, statusElem, timeout}).catch(console.warn);
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolve(msg.data.event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onerror = (err) => {
|
||||||
|
worker.terminate();
|
||||||
|
// promptError(msg.data.error, {});
|
||||||
|
cancelBtn.removeEventListener('click', onCancel);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.postMessage({event: evt, difficulty, timeout});
|
||||||
|
});
|
||||||
|
};
|
42
client/src/func/worker.js
Normal file
42
client/src/func/worker.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import {getEventHash} from 'nostr-tools';
|
||||||
|
import {zeroLeadingBitsCount} from './utils/crypto';
|
||||||
|
|
||||||
|
const 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 {id, ...event};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addEventListener('message', (msg) => {
|
||||||
|
const {difficulty, event, timeout} = msg.data;
|
||||||
|
try {
|
||||||
|
const minedEvent = mine(event, difficulty, timeout);
|
||||||
|
postMessage({event: minedEvent});
|
||||||
|
} catch (err) {
|
||||||
|
postMessage({error: err});
|
||||||
|
}
|
||||||
|
});
|
25
client/src/utils/crypto.ts
Normal file
25
client/src/utils/crypto.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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: string) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue
Block a user