I wanted to learn more about Nostr, so I decided to implement libraries and clients.
./client
: node client example./client-web
: react client example (NWorker
,RelayClient
,NUser
React, Chakra, Zustand)./relay-docker
: high-performance gnost-relay docker image for local testing@nostr-ts/common
:./packages/common
: common types and functions@nostr-ts/node
:./packages/node
: client for usage with nodews
library@nostr-ts/web
:./packages/web
: client for usage with browserWebSocket
API
If you want to know what I think about Nostr and how it compares to Mastodon, Matrix and others, checkout this article.
Nostr web client built with React.
- Relies on IndexedDB and local storage for data and accounts
- implements
@nostr-ts/common
and@nostr-ts/web
Initial support for nos2x
and any other extention following NIP-07 is available.
A new, live version builds from master on every commit: https://d2okqj4v2u9fts.cloudfront.net.
- Supported NIP: 1, 2, 4, 10, 11, 13, 14, 18, 23, 25, 36, 39, 40, 42, 45, 56
- Partial NIP: 19, 32, 57
RelayClient
to handle websocket connection and message sending (node, web)RelayDiscovery
to make it easy to pickup new relays (node)NEvent
to assemble events (universal)NFilters
to filter events (universal)NUser
to handle user metadata (node, web - WIP)NWorker
to handle client-side processing and database (web)loadOrCreateKeypair
basic key handling (node, web)
The goal here is to make it as easy as possible to get started, so there's usually a convenience function for everything (NewShortTextNote, NewRecommendRelay, ...).
- Sattelite CDN (web)
On Node.js use:
import { NewShortTextNote, NFilters } from "@nostr-ts/common";
import {
RelayClient,
RelayDiscovery,
loadOrCreateKeypair,
NUser,
} from "@nostr-ts/node";
install with:
# or npm install, or yarn install
pnpm install @nostr-ts/common @nostr-ts/node
In the browser use:
import { NewShortTextNote, NFilters } from "@nostr-ts/common";
import { RelayClient, loadOrCreateKeypair, NUser } from "@nostr-ts/web";
install with:
# or npm install, or yarn install
pnpm install @nostr-ts/common @nostr-ts/web
So most types and utility functions comes from @nostr-ts/common
, and anything related to file system, database or networking (requests), is in @nostr-ts/node
and @nostr-ts/web
.
pnpm install -r
pnpm run build
The build command will take care of ./packages/*
.
Generate a keypair (Sign up):
const keypair = await loadOrCreateKeypair("./key");
- This will look for a private key
./key
and a public key./key.pub
- If they don't exist, they will be generated and saved to disk
- If only the private key
./key
exists, a public key will be generated from it
Connect to the network:
let client = new RelayClient([
{
url: "wss://nostr.rocks",
read: true,
write: true,
},
{
url: "wss://nostr.lu.ke",
read: true,
write: true,
},
]);
await client.getRelayInformation();
Send a message:
const event = NewShortTextNote({ text: "Hello nostr!" });
event.signAndGenerateId(keypair);
client.sendEvent({ event });
Receive messages:
const filters = new NFilters();
filters.addAuthor(keypair.pub);
client.subscribe({
filters,
});
client.listen((payload) => {
console.log(payload.meta.id, payload.meta.url);
logRelayMessage(payload.data);
});
Recommend a relay
const event = NewRecommendRelay({
relayUrl: "wss://nostr.rocks",
});
event.signAndGenerateId(keypair);
client.sendEvent({ event });
Supported messages (events)
NewShortTextNote
: Send a short text noteNewLongFormContent
: Send a long form content noteNewShortTextNoteResponse
: Respond to a short text noteNewReaction
: React to a note (+
,-
)NewQuoteRepost
: Repost a noteNewGenericRepost
: Report any eventNewUpdateUserMetadata
: Update user metadata (profile)NewRecommendRelay
: Recommend a relayNewReport
: Report an event or userNewZapRequest
: Request a zapNewSignedZapRequest
: Request a zap helperNewZapReceipt
: Zap receiptNewEventDeletion
: Delete an event
Event
You can manually assemble an event:
const event = new NEvent({
kind: NEVENT_KIND_SHORT_TEXT_NOTE,
tags: [],
content: 'Hello nostr!',
})
// These are all the options; you do not (and usually should not) use all of them
// If something doesn't add-up, these sometimes throw an error
event.addEventTag(...)
event.addPublicKeyTag(...)
event.addRelaysTag(...)
event.addEventCoordinatesTag(...)
event.addIdentifierTag(...)
event.addLnurlTag(...)
event.addAmountTag(...)
event.addKindTag(...)
event.addExpirationTag(...)
event.addSubjectTag(...)
event.addSubjectTag(makeSubjectResponse(subject));
event.addNonceTag(...)
event.addContentWarningTag(...)
event.addExternalIdentityClaimTag(...)
event.addReportTags(...)
// Add custom tags
event.addTag(['p', 'myvalue'])
// Mentions in event content; for ex. Checkout nostr:e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466
event.mentionUsers([pubkey1, pubkey2])
event.hasMentions()
// Sign
event.signAndGenerateId(keypair)
// Ready to publish?
const ready = event.isReadyToPublish()
// Required NIP? [13, 39, 40]
const nip = event.determineRequiredNIP()
// Properties
event.hasPublicKeyTags()
event.hasRelaysTag()
event.hasEventCoordinatesTags()
event.hasIdentifierTags()
event.hasLnurlTags()
event.hasAmountTags()
event.hasExpirationTag()
event.hasSubjectTag()
event.hasNonceTag()
event.hasContentWarningTag()
event.hasExternalIdentityClaimTag()
event.hasReportTags()
const event = NewContactList({
contacts: [
{
key: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
relayUrl: "wss://nostr.rocks",
petname: "nostrop",
},
],
});
- NIP-4 Encrypted Direct Message
const event = NewEncryptedPrivateMessage({
text: "Let's make this secret plan happen!",
recipientPubkey: "...",
});
const encEv = await encryptEvent(event, keypair);
event.content = encEv.content as string;
event.signAndGenerateId(keypair);
This is a bit ugly, as I did not want to include the encryption library in the common package.
Here's what it might looks like:
import crypto from "crypto";
import { getSharedSecret } from "@noble/secp256k1";
export async function encryptEvent(
event: EventBase,
keyPair: {
privateKey: string,
publicKey: string,
}
) {
const recipientPublicKey = event.tags ? event.tags[0][1] : undefined;
if (!recipientPublicKey) {
throw new Error(
"No recipient public key set. Did you use NewEncryptedPrivateMessage?"
);
}
let sharedPoint = await getSharedSecret(
keyPair.privateKey,
"02" + recipientPublicKey
);
let sharedX = sharedPoint.slice(1, 33);
let iv = crypto.randomFillSync(new Uint8Array(16));
var cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(sharedX), iv);
let encryptedMessage = cipher.update(event.content || "", "utf8", "base64");
encryptedMessage += cipher.final("base64");
let ivBase64 = Buffer.from(iv.buffer).toString("base64");
event.content = encryptedMessage + "?iv=" + ivBase64;
return event;
}
Adapted from this example: github.com/nostr-protocol/nips/blob/master/04.
- NIP-11 Relay Information Document
const infos = await client.getRelayInformation();
Based on this information the client decides whether to publish to a rely:
neededNips [ 40 ]
supportedNips [
1, 2, 4, 9, 11,
12, 15, 16, 20, 22,
28, 33
]
Event a04308c18a5f73b97be1f66fddba1741dd8dcf8a057701a2b4f1713d557ae384 not published to wss://nostr.wine because not all needed NIPS are supported.
- NIP-13 Proof of work
const difficulty = 28;
const event = NewShortTextNote({
text: "Let's have a discussion about Bitcoin!",
});
event.pubkey = keypair.pub;
event.proofOfWork(difficulty);
event.sign();
If you need anything above ~20 bits and work in the browser, there's a helper function for web worker (proofOfWork(event, bits)
):
// pow-worker.ts
import { proofOfWork } from "@nostr-ts/common";
self.onmessage = function (e) {
const data = e.data;
const result = proofOfWork(data.event, data.bits);
self.postMessage({ result });
};
// client.ts
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./pow-worker.ts", import.meta.url), {
type: "module",
});
// Setup an event listener to receive results from the worker
worker.onmessage = function (e) {
resolve(e.data.result);
// Terminate the worker after receiving the result
worker.terminate();
};
// Send a message to the worker to start the calculation
worker.postMessage({
event: event,
bits: bits,
});
});
- NIP-14 Subject tag in Text events
const event = NewShortTextNote({
text: "Let's have a discussion about Bitcoin!",
});
event.addSubjectTag("All things Bitcoin");
If you want to respond to a note, keeping the subject:
const inResponseTo = {
id: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466",
pubkey: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
created_at: 1690881792,
kind: 1,
tags: [["subject", "All things Bitcoin"]],
content: "Let's have a discussion about Bitcoin!",
sig: "6cee8c1d11ca5f8c7a0bd9839d0af5d3af3cc6a5de754fc449d34188c0066eee3e5b5b4e567cd77a2e0369f8c9525d60e064db175acd02d9c5374c3c0e912969",
};
const relayUrl = "wss://nostr.rocks";
const event = NewShortTextNoteResponse({
text: "Sounds like a great idea. What do you think about the Lightning Network?",
inResponseTo,
relayUrl,
});
If this is the first response, we prepend the subject with Re:
automatically. So you'd be responding with subject Re: All things Bitcoin
.
- NIP-18 Reposts
const inReponseTo = {
id: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466",
pubkey: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
created_at: 1690881792,
kind: 1,
tags: [],
content:
"Hello everyone! I am working on a new ts library for nostr. This is just a test.",
sig: "6cee8c1d11ca5f8c7a0bd9839d0af5d3af3cc6a5de754fc449d34188c0066eee3e5b5b4e567cd77a2e0369f8c9525d60e064db175acd02d9c5374c3c0e912969",
};
const event = NewQuoteRepost({
relayUrl: "https://nostr.rocks",
inReponseTo,
});
event.signAndGenerateId(keypair);
client.sendEvent({ event });
You can also utilize NewGenericRepost
to repost any kind of event.
- NIP-19 bech32-encoded entities
There are some helpers to get you started:
encodeBech32(...)
decodeBech32(...)
Shortcuts to the above, for specific use cases:
bechEncodeProfile(pubkey, relayUrls)
(returns ex.nprofile...
)bechEncodePublicKey(pubkey)
bechEncodePrivateKey(privkey)
decodeNostrPublicKeyString(nostr:npub...)
(returns public key)decodeNostrPrivateKeyString(nostr:nsec...)
decodeNostrProfileString(nostr:nprofile...)
encodeNostrString(prefix, tlvItems)
(returns ex.nostr:npub...
)
Examples public keys:
const src =
"nostr:npub1kade5vf37snr4hv5hgstav6j5ygry6z09kkq0flp47p8cmeuz5zs7zz2an";
const resO1 = decodeNostrPublicKeyString(src);
// res = b75b9a3131f4263add94ba20beb352a11032684f2dac07a7e1af827c6f3c1505
const resO2 = decodeNostrUrl(src);
// res = [{ prefix: 'npub', tlvItems: [{ type: 0, value: "b75b9a3131f4263add94ba20beb352a11032684f2dac07a7e1af827c6f3c1505" }] }]
Example profile:
const pubkey =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const relays = ["wss://r.x.com", "wss://djbas.sadkb.com"];
const res01 = bechEncodeProfile(pubkey, relays);
// res01 = nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p
const res02 = makeNostrProfileString(pubkey, relays);
// res02 = nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p
- NIP-23 Long-form Content
const event = NewLongFormContent({
text: "This is a really long one. Like I mean, not your usual short note. This is a long one. I mean, really long. Like, really really long. Like, really really really long. Like, really really really really long. Like, really really really really really long. Like, really really really really really really long."
isDraft: false,
identifier: "really-really-really-long"
})
- NIP-25: Reactions
const event = NewReaction({
text: "+",
inResponseTo: {
id: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466",
pubkey: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
},
});
event.signAndGenerateId(keypair);
client.sendEvent({ event });
const event = NewShortTextNote({
text: "This is a test note with explicit language.",
});
event.addContentWarningTag("explicit language");
const githubClaim = new ExternalIdentityClaim({
type: IDENTITY_CLAIM_TYPE.GITHUB,
identity: "semisol",
proof: "9721ce4ee4fceb91c9711ca2a6c9a5ab",
});
const event = NewUpdateUserMetadata({
claims: [githubClaim],
userMetadata: {
name: "Semisol",
},
});
event.signAndGenerateId(keypair);
client.sendEvent({ event });
- NIP-40 Expiration Timestamp
const event = NewShortTextNote({ text: "Meeting starts in 10 minutes ..." });
event.addExpirationTag(1690990889);
As far as I understand, relays should send the auth challenge either on connection, or when required. The relay I'm testing with (gnost-relay) sends it on connection.
Here's how you can respond to the challenge:
const challenge = "abc";
const event = NewAuthEvent({
relayUrl: "wss://nostr-ts.relay",
challenge: challenge,
});
event.signAndGenerateId(keypair);
client.subscribe({
type: CLIENT_MESSAGE_TYPE.AUTH,
signedEvent: JSON.stringify(event.ToObj()),
});
- NIP-56 Reporting
The publicKey
usually refers to the user that is being reported.
If the report refers to another event, use the eventId
too (for ex. spam, illegal, profanity, nudity).
Impersonation:
const event = NewReport({
publicKey: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
kind: NREPORT_KIND.IMPERSONATION,
});
Spam:
const event = NewReport({
publicKey: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
eventId: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466",
kind: NREPORT_KIND.SPAM,
// optionally pass some text
content: "This is spam",
});
- NIP-57 Lightning Zaps
This is a really rudimentary example to show the steps required. I will follow-up with a more realistig implementation.
Supports:
- Zap to a user: YES
- Zap to from / to event: (just make sure you include the event ID in the event)
const recipient = new NUser({
pubkey: "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70",
});
// Get filters for subscription to get user information
const filters = recipient.getMetadataFilter();
let client = new RelayClient([
{
url: "wss://nostr.rocks",
read: true,
write: true,
},
{
url: "wss://nostr.lu.ke",
read: true,
write: true,
},
]);
await client.getRelayInformation();
client.subscribe({
filters,
});
client.listen(async (payload) => {
console.log(payload.meta.id, payload.meta.url);
logRelayMessage(payload.data);
// Don't actually do exactly this
// for ex. if you're subscribed to multiple relays, you'll generate multiple payments
// This should be part of client logic
if (payload.data[0] === RELAY_MESSAGE_TYPE.EVENT) {
// Load user data from event
const success = recipient.fromEvent(payload.data[2]);
if (success) {
// Make ZAP request
const { pr: invoice, event } = await recipient.makeZapRequest(
{
relayUrls: ["wss://nostr.rocks"],
amount: 1000,
},
keypair
);
// Pay invoice with lightning wallet then continue here
const bolt11FromYourWallet = "lnbc1...";
const receipt = event.newZapReceipt({
bolt11: bolt11FromYourWallet,
description: "Keep stacking sats!",
});
receipt.signAndGenerateId(keypair);
client.sendEvent({ receipt });
}
}
});
- Setup a filter for kind 2
- Subscribe with the filter
- Pass incoming events to discovery
- Save to json file
import { NFilters, logRelayMessage } from "@nostr-ts/common";
import {
loadOrCreateKeypair,
RelayClient,
RelayDiscovery,
} from "@nostr-ts/node";
const main = async () => {
const keypair = await loadOrCreateKeypair();
let client = new RelayClient([
{
url: "wss://nostr.rocks",
read: true,
write: true,
},
{
url: "wss://nostr.lu.ke",
read: true,
write: true,
},
]);
const relayDiscovery = new RelayDiscovery();
const filters = new NFilters();
filters.addKind(NEVENT_KIND.RECOMMEND_RELAY);
client.subscribe({
filters,
});
client.listen(async (payload) => {
await relayDiscovery.add(payload.data);
});
await client.getRelayInformation();
await new Promise((resolve) => setTimeout(resolve, 1 * 30 * 1000)).then(
async () => {
client.closeConnection();
await relayDiscovery.saveToFile();
}
);
};
main();
You will get two files
discovered-relays.json
with all valid relaysdiscovered-relays-error.json
with all invalid relays
This is what an excerpt of discovered-relays.json
looks like (a more complete one is included in this repo):
[
{
"url": "wss://relay.nostrplebs.com",
"info": {
"contact": "nostr@semisol.dev",
"description": "Nostr Plebs paid relay.",
"name": "relay.nostrplebs.com",
"pubkey": "52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd",
"software": "git+https://github.com/hoytech/strfry.git",
"supported_nips": [1, 9, 11, 12, 15, 16, 20, 22],
"version": "v92-84ba68b"
}
},
{
"url": "wss://nostr-pub.wellorder.net",
"info": {
"id": "wss://nostr-pub.wellorder.net/",
"name": "Public Wellorder Relay",
"description": "Public relay for nostr development and use.",
"pubkey": "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f",
"contact": "mailto:relay@wellorder.net",
"supported_nips": [1, 2, 9, 11, 12, 15, 16, 20, 22, 33, 40, 42],
"software": "https://git.sr.ht/~gheartsfield/nostr-rs-relay",
"version": "0.8.9",
"limitation": {
"payment_required": false
}
}
},
{
"url": "wss://relay.nostrview.com",
"info": {
"name": "relay.nostrview.com",
"description": "Nostrview relay",
"pubkey": "2e9397a8c9268585668b76479f88e359d0ee261f8e8ea07b3b3450546d1601c8",
"contact": "2e9397a8c9268585668b76479f88e359d0ee261f8e8ea07b3b3450546d1601c8",
"supported_nips": [
1, 2, 4, 9, 11, 12, 15, 16, 20, 22, 26, 28, 33, 40, 111
],
"software": "git+https://github.com/Cameri/nostream.git",
"version": "1.22.2",
"limitation": {
"max_message_length": 524288,
"max_subscriptions": 10,
"max_filters": 10,
"max_limit": 5000,
"max_subid_length": 256,
"min_prefix": 4,
"max_event_tags": 2500,
"max_content_length": 102400,
"min_pow_difficulty": 0,
"auth_required": false,
"payment_required": true
},
"payments_url": "https://relay.nostrview.com/invoices",
"fees": {
"admission": [
{
"amount": 4000000,
"unit": "msats"
}
]
}
}
}
]
and here's discovered-relays-error.json
:
[
{
"url": "wss://nostr.rocks"
},
{
"url": "wss://rsslay.fiatjaf.com"
},
{
"url": "wss://nostr.rdfriedl.com"
},
{
"url": "wss://expensive-relay.fiatjaf.com"
},
{
"url": "wss://relayer.fiatjaf.com"
},
{
"url": "wss://nostr-relay.wlvs.space"
}
]
Once you've collected a list of relays, you can feed them to Relay Client.
A couple of points:
- You might not want to connect to hundreds of relays at once
- I will add some randomization and limits in the future
const client = new RelayClient();
const relayDiscovery = new RelayDiscovery();
await relayDiscovery.loadFromFile();
await client.loadFromDiscovered(relayDiscovery.get());
// Now continue as usual ...
const filters = new NFilters();
filters.addKind(1);
client.subscribe({
filters,
});
client.listen(async (payload) => {
logRelayMessage(payload.data);
});
await client.getRelayInformation();
If you prefer to apply limits yourself, you could do something like this:
const relays = relayDiscovery.get().slice(0, 10);
await client.loadFromDiscovered(relays);
The @nostr-ts/web
package includes a Sattelite CDN implementation.
const keypair = generateClientKeys();
// Request credit (1 GB)
const request = sCDNCreditRequest(1);
request.signAndGenerateId(keypair);
// Get terms
const terms = await sCDNGetTerms(request);
// Accept terms (sign)
// as of writing, the amount would be 184000 msats
const payment = new NEvent(terms.payment);
payment.signAndGenerateId(keypair);
// Get invoice
const invoice = await sCDNGetInvoice(terms, payment);
// invoice.pr contains the lightning invoice
If you're new to Nostr, also checkout awesome-nostr.