Compare commits

...

83 Commits

Author SHA1 Message Date
fiatjaf
aec8ff5946 fix for updated typescript. 2025-04-02 11:44:41 -03:00
fiatjaf
e498c9144d nip46: auto-reconnect. 2025-04-02 10:58:26 -03:00
fiatjaf
42d47abba1 update readme and add more examples. 2025-04-02 10:53:33 -03:00
fiatjaf
303c35120c pool: deprecate subscribeManyMap and introduce subscribe/subscribeEose methods that take a single filter. 2025-04-02 10:37:10 -03:00
fiatjaf
4a738c93d0 nip46: stop supporting nip04-encrypted messages. 2025-04-02 10:25:19 -03:00
fiatjaf
2a11c9ec91 nip04: functions shouldn't be async. 2025-04-02 10:19:27 -03:00
fiatjaf
cbe3a9d683 pool subscribe methods accept an onauth param. 2025-04-01 19:16:42 -03:00
fiatjaf
2944a932b8 nip46: mark connection as closed when relays disconnect. 2025-03-29 18:03:39 -03:00
codytseng
6b39de04d7 Fix auth() not returning on consecutive calls 2025-03-17 13:31:24 -03:00
fiatjaf
9a612e59a2 update nip11 test. 2025-03-14 09:30:35 -03:00
fiatjaf
266dbdf766 nip27: rewrite to support urls and references in a simpler API for rich UIs. 2025-03-14 09:26:40 -03:00
fiatjaf
19ae9837a7 nip19: decodeNostrURI() function that doesn't throw. 2025-03-14 09:26:40 -03:00
António Conselheiro
4188f2c596 Generic repost 2025-03-10 01:58:00 -03:00
fiatjaf
97bded8f5b prevent a relay from eoseing then closing and causing pool handlers to fire twice. 2025-03-02 01:25:39 -03:00
fiatjaf
174d36a440 nip07: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
0177b130c3 nip55: remove getRelays() 2025-03-02 01:25:39 -03:00
fiatjaf
05eb62da5b support subscription label, not only an absolute id. 2025-03-02 01:25:39 -03:00
Baris Aydek
3c4019a154 nip54 normalizeIdentifier function 2025-02-25 13:52:40 -03:00
fiatjaf
e7e8db1dbd nip46: take EventTemplate instead of UnsignedEvent. 2025-02-24 14:48:47 -03:00
bitcoinpirate
44a679e642 added support for zapping replaceable events (#424)
* added support for zapping replaceable events

* Update nip57.ts

* Update nip57.ts

Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>

* apply @SnowCait's suggestions.

* fix lint error.

---------

Co-authored-by: AsaiToshiya <to.asai.60@gmail.com>
Co-authored-by: 雪猫 <SnowCait@users.noreply.github.com>
2025-02-24 00:46:51 +09:00
Asai Toshiya
c1172caf1d mark getRelays and get_relays as deprecated. 2025-02-21 15:08:55 -03:00
Jon Staab
86f37d6003 Clean up nip96 upload validation and make it less strict 2025-02-11 15:58:20 -03:00
Sandwich
3daade322c export retention details
pain to use without being available as an export.
2025-02-10 09:25:33 -03:00
Asai Toshiya
fcf10541c8 rename "parameterized replaceable" to "addressable". 2025-01-23 14:08:22 -03:00
Asai Toshiya
548abb5d4a nip18: tweak test data. 2025-01-23 20:03:52 +09:00
Asai Toshiya
1e5bfe856b nip18: don't stringify protected event. 2025-01-17 21:30:09 -03:00
Anderson Juhasc
3266b4d4c2 added NIP-55 2025-01-04 14:15:11 -03:00
Asai Toshiya
a0b950ab12 remove unnecessary id from Omit keys. 2025-01-02 15:57:39 -03:00
Asai Toshiya
be741159d7 nip29: update GroupAdminPermission. 2024-12-17 13:33:00 -03:00
im-adithya
9c50b2c655 fix: clear timeout in publish and auth 2024-12-03 11:09:11 -03:00
Egge
bbb09420fe export nip17 2024-11-26 11:59:58 -03:00
Asai Toshiya
2e85f7a5fe Revert "nip19: remove note1."
This reverts commit a8a805fb71.
2024-11-26 11:59:58 -03:00
Asai Toshiya
b22e2465cc nip19: remove note1. 2024-11-26 11:59:58 -03:00
fiatjaf
43ce7f9377 fix reference to nostr-wasm dependency so it can be installed on deno.
fixes https://github.com/nbd-wtf/nostr-tools/issues/459
2024-11-25 21:33:25 -03:00
fiatjaf
5a55c670fb nip10: fix. 2024-11-13 01:21:54 -03:00
fiatjaf
bf0c4d4988 nip10: improve, support quotes, author hints, change the way legacy refs are discovered. 2024-11-04 15:37:39 -03:00
fiatjaf
50fe7c2a8b streamline jsr publishes. 2024-11-02 08:35:52 -03:00
fiatjaf
29270c8c9d nip46: fix legacyDecrypt argument. 2024-11-02 08:13:33 -03:00
fiatjaf
cb29d62033 add links to jsr.io 2024-10-31 16:33:24 -03:00
Asai Toshiya
0d237405d9 fix lint error. 2024-10-31 20:43:03 +09:00
Asai Toshiya
659ad36b62 nip05: use stub to test queryProfile(). 2024-10-31 07:51:53 -03:00
Asai Toshiya
d062ab8afd make publish() timeout. 2024-10-30 11:55:04 -03:00
Fishcake
94f841f347 Fix fetch to work in the edge and node environments, cleanup type issues
- Fix "TypeError: Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code)." that is thrown when trying to use fetch in the edge environment  (e.g., workers)
- Cleanup types and variable definitions.
2024-10-28 14:56:10 -03:00
fiatjaf
c1d03cf00b nip46: only encrypt with nip44 (breaking). 2024-10-27 14:59:27 -03:00
fiatjaf
29ecdfc5ec fix slow types so we can publish to jsr.io 2024-10-26 14:23:28 -03:00
fiatjaf
d3fc4734b4 export missing modules. 2024-10-26 13:10:26 -03:00
fiatjaf
66d0b8a4e1 nip46: export queryBunkerProfile() 2024-10-26 07:24:13 -03:00
fiatjaf
e2ec7a4b55 fix types, imports and other stuff on nip17 and nip59. 2024-10-25 22:10:05 -03:00
fiatjaf
a72e47135a nip46: we have no business checking the pubkey of the sign_event result. 2024-10-25 21:58:03 -03:00
Anderson Juhasc
de7bbfc6a2 organizing and improving nip17 and nip59 2024-10-25 11:49:28 -03:00
fiatjaf
f2d421fa4f nip46: remove "nip44_get_key" method as it was removed from the spec. 2024-10-25 10:22:21 -03:00
Anderson Juhasc
cae06fc4fe Implemented NIP-17 support (#449) 2024-10-24 21:10:09 -03:00
fiatjaf
5c538efa38 nip28: fix naming bug. 2024-10-23 17:11:35 -03:00
fiatjaf
013daae91b nip05: fix test. 2024-10-23 17:09:56 -03:00
fiatjaf
75660e7ff1 nip46: cache the received pubkey. 2024-10-23 17:09:41 -03:00
fiatjaf
4c2d2b5ce6 nip46: fix getPublicKey() by making it actually call "get_public_key". 2024-10-23 16:38:14 -03:00
António Conselheiro
aba266b8e6 Suggestion: export kinds as named types (#447)
* including kinds for nip17 and nip59

* including kinds as types

* solving linter with prettier
2024-10-23 10:39:39 -03:00
Asai Toshiya
d7dcc75ebe nip19: completely remove nrelay. 2024-10-22 13:06:34 -03:00
fiatjaf
b18510b460 nip13: speed improvements. 2024-10-22 13:03:29 -03:00
Egge
b04e0d16c0 test: fixed nip06 assertion 2024-10-20 10:53:08 -03:00
Egge
633696bf46 nip06: return Uint8 instead of string 2024-10-20 10:53:08 -03:00
ciegovolador
bf975c9a87 fix(nip59) formated code 2024-10-18 07:20:37 -03:00
fiatjaf
7aa4f09769 tag v2.8.1 2024-10-17 21:57:38 -03:00
Egge
f646fcd889 export nip59 2024-10-17 21:51:03 -03:00
ciegovolador
1d89038375 add nip59 (#438)
* feat(nip59) add nip59 based on https://nips.nostr.com/59

* fix(nip59) export the code as nip59

* Update nip59.ts

Co-authored-by: Asai Toshiya <to.asai.60@gmail.com>

* fix(nip59) change GiftWrap kind and using kinds from kinds.ts

---------

Co-authored-by: Asai Toshiya <to.asai.60@gmail.com>
2024-10-17 09:38:04 -03:00
Callum Macdonald
0b5b35714c Add subscription id to relay.subscribe().
fix #439
2024-10-17 09:30:49 -03:00
Vinit
e398617fdc chore: use describe block to group NostrTypeGuard tests
this way we don't need to repeat NostrTypeGuard in each tests description
2024-10-11 07:48:46 -03:00
Vinit
1b236faa7b fix: move NostrTypeGuard tests to nip19.test.ts
NostrTypeGuard was moved to nip19.ts in commit 45b25c5bf5
but tests stayed in core.test.ts and started failing because it still imported
NostrTypeGuard from core.ts - which wasn't there.
2024-10-11 07:48:46 -03:00
Asai Toshiya
7064e0b828 make it possible to track relays on publish. 2024-10-10 14:45:37 -03:00
space-shell
4f6976f6f8 fix nip44Decrypt sending "nip44_encrypt" request (#435)
nip44Decrypt sends nip44_decrypt request
2024-09-24 12:56:36 -03:00
António Conselheiro
a61cde77ea Kinds from nip17 nip59 (#434) 2024-09-20 17:05:52 -03:00
fiatjaf
23d95acb26 move Nip05 type to nip05.ts 2024-09-09 14:23:03 -03:00
fiatjaf
13ac04b8f8 nip19: fix ncryptsec type guard.
see https://github.com/nbd-wtf/nostr-tools/pull/409#issuecomment-2338661000
2024-09-09 14:21:29 -03:00
fiatjaf
45b25c5bf5 nip19/nip49: remove nrelay and move bech32 string guard methods from core to nip19. 2024-09-09 14:20:35 -03:00
António Conselheiro
ee76d69b4b including nostr specialized types (#409)
* including nostr types

* including tests for nostr type guard

* fix tests for nostr type guard

* fix linter and add eslint and prettier to devcontainer

* including null in nostr type guard signature

* fix type, ops

* including ncryptsec in nostr type guard

* fix linter for ncryptsec

* including ncryptsec return type for nip49

* fixing names of nostr types and types guards

* fixing names of nostr types and types guards in unit tests descriptions

* fix prettier

* including type guard for nip5
2024-09-09 14:16:23 -03:00
Vinit
21433049b8 fix: eslint no-unused vars violations and setup
@typescript-eslint documents recommends turning off the base
unused-vars rule in eslint explicitly and using '@typescript-eslint/no-unused var'
instead. In this case, the base rule failed to correctly report enums. (reported
unused even though they were used).

Also, fixed two unused variables by adding ignore pattern '_'.
2024-09-06 19:45:50 -03:00
Asai Toshiya
e8ff68f0b3 Update README.md 2024-08-15 11:29:18 -03:00
fiatjaf
1b77d6e080 mention nostrify.dev on readme. 2024-08-08 12:13:41 -03:00
Sam Samskies
76d3a91600 authorization should not be in the form data 2024-08-08 12:00:58 -03:00
Sepehr Safari
6f334f31a7 add and improve helpers for nip29 2024-08-01 18:18:13 -03:00
Alex Gleason
9c009ac543 getFilterLimit: handle parameterized replaceable events 2024-07-20 22:00:28 -03:00
Shusui MOYATANI
a87099fa5c remove Content-Type header from NIP-96 uploadFile 2024-07-20 09:44:45 -03:00
António Conselheiro
475a22a95f methods for abstract pool (#419)
* include method to list current pool relays connections and to close all connections

* fix prettier
2024-07-18 13:33:29 -03:00
49 changed files with 2798 additions and 671 deletions

View File

@@ -1,6 +1,6 @@
FROM node:20 FROM node:20
RUN npm install typescript -g RUN npm install typescript eslint prettier -g
# Install bun # Install bun
RUN curl -fsSL https://bun.sh/install | bash RUN curl -fsSL https://bun.sh/install | bash

View File

@@ -116,7 +116,8 @@
"no-unexpected-multiline": 2, "no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }], "no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2, "no-unreachable": 2,
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }], "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_" }],
"no-useless-call": 2, "no-useless-call": 2,
"no-useless-constructor": 2, "no-useless-constructor": 2,
"no-with": 2, "no-with": 2,

221
README.md
View File

@@ -1,19 +1,27 @@
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) nostr-tools # ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) [![JSR](https://jsr.io/badges/@nostr/tools)](https://jsr.io/@nostr/tools) nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients. Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages. Only depends on _@scure_ and _@noble_ packages.
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system). This package is only providing lower-level functionality. If you want more higher-level features, take a look at [Nostrify](https://nostrify.dev), or if you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
## Installation ## Installation
```bash ```bash
npm install nostr-tools # or yarn add nostr-tools # npm
npm install --save nostr-tools
# jsr
npx jsr add @nostr/tools
``` ```
If using TypeScript, this package requires TypeScript >= 5.0. If using TypeScript, this package requires TypeScript >= 5.0.
## Documentation
https://jsr.io/@nostr/tools/doc
## Usage ## Usage
### Generating a private key and a public key ### Generating a private key and a public key
@@ -49,43 +57,43 @@ let event = finalizeEvent({
let isGood = verifyEvent(event) let isGood = verifyEvent(event)
``` ```
### Interacting with a relay ### Interacting with one or multiple relays
Doesn't matter what you do, you always should be using a `SimplePool`:
```js ```js
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure' import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { Relay } from 'nostr-tools/relay' import { SimplePool } from 'nostr-tools/pool'
const relay = await Relay.connect('wss://relay.example.com') const pool = new SimplePool()
console.log(`connected to ${relay.url}`)
// let's query for an event that exists // let's query for an event that exists
const sub = relay.subscribe([ const event = relay.get(
['wss://relay.example.com'],
{ {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'], ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
}, },
], { )
onevent(event) { if (event) {
console.log('we got the event we wanted:', event) console.log('it exists indeed on this relay:', event)
}, }
oneose() {
sub.close()
}
})
// let's publish a new event while simultaneously monitoring the relay for it // let's publish a new event while simultaneously monitoring the relay for it
let sk = generateSecretKey() let sk = generateSecretKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)
relay.sub([ pool.subscribe(
['wss://a.com', 'wss://b.com', 'wss://c.com'],
{ {
kinds: [1], kinds: [1],
authors: [pk], authors: [pk],
}, },
], { {
onevent(event) { onevent(event) {
console.log('got event:', event) console.log('got event:', event)
}
} }
}) )
let eventTemplate = { let eventTemplate = {
kind: 1, kind: 1,
@@ -96,7 +104,7 @@ let eventTemplate = {
// this assigns the pubkey, calculates the event id and signs the event in a single step // this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(eventTemplate, sk) const signedEvent = finalizeEvent(eventTemplate, sk)
await relay.publish(signedEvent) await pool.publish(['wss://a.com', 'wss://b.com'], signedEvent)
relay.close() relay.close()
``` ```
@@ -111,59 +119,116 @@ import WebSocket from 'ws'
useWebSocketImplementation(WebSocket) useWebSocketImplementation(WebSocket)
``` ```
### Interacting with multiple relays ### Parsing references (mentions) from a content based on NIP-27
```js ```js
import { SimplePool } from 'nostr-tools/pool' import * as nip27 from '@nostr/tools/nip27'
const pool = new SimplePool() for (let block of nip27.parse(evt.content)) {
switch (block.type) {
let relays = ['wss://relay.example.com', 'wss://relay.example2.com'] case 'text':
console.log(block.text)
let h = pool.subscribeMany( break
[...relays, 'wss://relay.example3.com'], case 'reference': {
[ if ('id' in block.pointer) {
{ console.log("it's a nevent1 uri", block.pointer)
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'], } else if ('identifier' in block.pointer) {
}, console.log("it's a naddr1 uri", block.pointer)
], } else {
{ console.log("it's an npub1 or nprofile1 uri", block.pointer)
onevent(event) { }
// this will only be called once the first time the event is received break
// ...
},
oneose() {
h.close()
} }
case 'url': {
console.log("it's a normal url:", block.url)
break
}
case 'image':
case 'video':
case 'audio':
console.log("it's a media url:", block.url)
case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url)
default:
break
} }
) }
await Promise.any(pool.publish(relays, newEvent))
console.log('published to at least one relay!')
let events = await pool.querySync(relays, { kinds: [0, 1] })
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
``` ```
### Parsing references (mentions) from a content using NIP-10 and NIP-27 ### Connecting to a bunker using NIP-46
```js ```js
import { parseReferences } from 'nostr-tools/references' import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
let references = parseReferences(event) // the client needs a local secret key (which is generally persisted) for communicating with the bunker
let simpleAugmentedContent = event.content const localSecretKey = generateSecretKey()
for (let i = 0; i < references.length; i++) {
let { text, profile, event, address } = references[i] // parse a bunker URI
let augmentedReference = profile const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
? `<strong>@${profilesCache[profile.pubkey].name}</strong>` if (!bunkerPointer) {
: event throw new Error('Invalid bunker input')
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>` }
: address
? `<a href="${text}">[link]</a>` // create the bunker instance
: text const pool = new SimplePool()
simpleAugmentedContent.replaceAll(text, augmentedReference) const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
await bunker.connect()
// and use it
const pubkey = await bunker.getPublicKey()
const event = await bunker.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello from bunker!'
})
// cleanup
await signer.close()
pool.close([])
```
### Parsing thread from any note based on NIP-10
```js
import * as nip10 from '@nostr/tools/nip10'
// event is a nostr event with tags
const refs = nip10.parse(event)
// get the root event of the thread
if (refs.root) {
console.log('root event:', refs.root.id)
console.log('root event relay hints:', refs.root.relays)
console.log('root event author:', refs.root.author)
}
// get the immediate parent being replied to
if (refs.reply) {
console.log('reply to:', refs.reply.id)
console.log('reply relay hints:', refs.reply.relays)
console.log('reply author:', refs.reply.author)
}
// get any mentioned events
for (let mention of refs.mentions) {
console.log('mentioned event:', mention.id)
console.log('mention relay hints:', mention.relays)
console.log('mention author:', mention.author)
}
// get any quoted events
for (let quote of refs.quotes) {
console.log('quoted event:', quote.id)
console.log('quote relay hints:', quote.relays)
}
// get any referenced profiles
for (let profile of refs.profiles) {
console.log('referenced profile:', profile.pubkey)
console.log('profile relay hints:', profile.relays)
} }
``` ```
@@ -197,32 +262,6 @@ declare global {
} }
``` ```
### Generating NIP-06 keys
```js
import {
privateKeyFromSeedWords,
accountFromSeedWords,
extendedKeysFromSeedWords,
accountFromExtendedKey
} from 'nostr-tools/nip06'
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' // optional
const accountIndex = 0
const sk0 = privateKeyFromSeedWords(mnemonic, passphrase, accountIndex)
const { privateKey: sk1, publicKey: pk1 } = accountFromSeedWords(mnemonic, passphrase, accountIndex)
const extendedAccountIndex = 0
const { privateExtendedKey, publicExtendedKey } = extendedKeysFromSeedWords(mnemonic, passphrase, extendedAccountIndex)
const { privateKey: sk2, publicKey: pk2 } = accountFromExtendedKey(privateExtendedKey)
const { publicKey: pk3 } = accountFromExtendedKey(publicExtendedKey)
```
### Encoding and decoding NIP-19 codes ### Encoding and decoding NIP-19 codes
```js ```js

View File

@@ -8,7 +8,7 @@ import {
} from './abstract-relay.ts' } from './abstract-relay.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
import type { Event, Nostr } from './core.ts' import type { Event, EventTemplate, Nostr, VerifiedEvent } from './core.ts'
import { type Filter } from './filter.ts' import { type Filter } from './filter.ts'
import { alwaysTrue } from './helpers.ts' import { alwaysTrue } from './helpers.ts'
@@ -16,14 +16,16 @@ export type SubCloser = { close: () => void }
export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {} export type AbstractPoolConstructorOptions = AbstractRelayConstructorOptions & {}
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & { export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose'> & {
maxWait?: number maxWait?: number
onclose?: (reasons: string[]) => void onclose?: (reasons: string[]) => void
doauth?: (event: EventTemplate) => Promise<VerifiedEvent>
id?: string id?: string
label?: string
} }
export class AbstractSimplePool { export class AbstractSimplePool {
protected relays = new Map<string, AbstractRelay>() protected relays: Map<string, AbstractRelay> = new Map()
public seenOn: Map<string, Set<AbstractRelay>> = new Map() public seenOn: Map<string, Set<AbstractRelay>> = new Map()
public trackRelays: boolean = false public trackRelays: boolean = false
@@ -60,10 +62,127 @@ export class AbstractSimplePool {
}) })
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
return this.subscribeManyMap(Object.fromEntries(relays.map(url => [url, filters])), params) return this.subscribeMap(
relays.map(url => ({ url, filter })),
params,
)
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
return this.subscribeMap(
relays.flatMap(url => filters.map(filter => ({ url, filter }))),
params,
)
}
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id)
if (!set) {
set = new Set()
this.seenOn.set(id, set)
}
set.add(relay)
}
}
const _knownIds = new Set<string>()
const subs: Subscription[] = []
// batch all EOSEs into a single
const eosesReceived: boolean[] = []
let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === requests.length) {
params.oneose?.()
handleEose = () => {}
}
}
// batch all closes into a single
const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i)
closesReceived[i] = reason
if (closesReceived.filter(a => a).length === requests.length) {
params.onclose?.(closesReceived)
handleClose = () => {}
}
}
const localAlreadyHaveEventHandler = (id: string) => {
if (params.alreadyHaveEvent?.(id)) {
return true
}
const have = _knownIds.has(id)
_knownIds.add(id)
return have
}
// open a subscription in all given relays
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => {
url = normalizeURL(url)
let relay: AbstractRelay
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
})
} catch (err) {
handleClose(i, (err as any)?.message || String(err))
return
}
let subscription = relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
if (reason.startsWith('auth-required:') && params.doauth) {
relay
.auth(params.doauth)
.then(() => {
relay.subscribe([filter], {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
subs.push(subscription)
}),
)
return {
async close() {
await allOpened
subs.forEach(sub => {
sub.close()
})
},
}
}
/**
* @deprecated Use subscribeMap instead.
*/
subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser { subscribeManyMap(requests: { [relay: string]: Filter[] }, params: SubscribeManyParams): SubCloser {
if (this.trackRelays) { if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => { params.receivedEvent = (relay: AbstractRelay, id: string) => {
@@ -83,6 +202,7 @@ export class AbstractSimplePool {
// batch all EOSEs into a single // batch all EOSEs into a single
const eosesReceived: boolean[] = [] const eosesReceived: boolean[] = []
let handleEose = (i: number) => { let handleEose = (i: number) => {
if (eosesReceived[i]) return // do not act twice for the same relay
eosesReceived[i] = true eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relaysLength) { if (eosesReceived.filter(a => a).length === relaysLength) {
params.oneose?.() params.oneose?.()
@@ -92,6 +212,7 @@ export class AbstractSimplePool {
// batch all closes into a single // batch all closes into a single
const closesReceived: string[] = [] const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => { let handleClose = (i: number, reason: string) => {
if (closesReceived[i]) return // do not act twice for the same relay
handleEose(i) handleEose(i)
closesReceived[i] = reason closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relaysLength) { if (closesReceived.filter(a => a).length === relaysLength) {
@@ -134,7 +255,28 @@ export class AbstractSimplePool {
let subscription = relay.subscribe(filters, { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => handleClose(i, reason), onclose: reason => {
if (reason.startsWith('auth-required:') && params.doauth) {
relay
.auth(params.doauth)
.then(() => {
relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: reason => {
handleClose(i, reason) // the second time we won't try to auth anymore
},
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
})
.catch(err => {
handleClose(i, `auth was required and attempted, but failed with: ${err}`)
})
} else {
handleClose(i, reason)
}
},
alreadyHaveEvent: localAlreadyHaveEventHandler, alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait, eoseTimeout: params.maxWait,
}) })
@@ -153,10 +295,24 @@ export class AbstractSimplePool {
} }
} }
subscribeEose(
relays: string[],
filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>,
): SubCloser {
const subcloser = this.subscribe(relays, filter, {
...params,
oneose() {
subcloser.close()
},
})
return subcloser
}
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filters: Filter[],
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'doauth'>,
): SubCloser { ): SubCloser {
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribeMany(relays, filters, {
...params, ...params,
@@ -170,11 +326,11 @@ export class AbstractSimplePool {
async querySync( async querySync(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event[]> { ): Promise<Event[]> {
return new Promise(async resolve => { return new Promise(async resolve => {
const events: Event[] = [] const events: Event[] = []
this.subscribeManyEose(relays, [filter], { this.subscribeEose(relays, filter, {
...params, ...params,
onevent(event: Event) { onevent(event: Event) {
events.push(event) events.push(event)
@@ -189,7 +345,7 @@ export class AbstractSimplePool {
async get( async get(
relays: string[], relays: string[],
filter: Filter, filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>, params?: Pick<SubscribeManyParams, 'label' | 'id' | 'maxWait'>,
): Promise<Event | null> { ): Promise<Event | null> {
filter.limit = 1 filter.limit = 1
const events = await this.querySync(relays, filter, params) const events = await this.querySync(relays, filter, params)
@@ -205,7 +361,29 @@ export class AbstractSimplePool {
} }
let r = await this.ensureRelay(url) let r = await this.ensureRelay(url)
return r.publish(event) return r.publish(event).then(reason => {
if (this.trackRelays) {
let set = this.seenOn.get(event.id)
if (!set) {
set = new Set()
this.seenOn.set(event.id, set)
}
set.add(r)
}
return reason
})
}) })
} }
listConnectionStatus(): Map<string, boolean> {
const map = new Map<string, boolean>()
this.relays.forEach((relay, url) => map.set(url, relay.connected))
return map
}
destroy(): void {
this.relays.forEach(conn => conn.close())
this.relays = new Map()
}
} }

View File

@@ -24,6 +24,7 @@ export class AbstractRelay {
public baseEoseTimeout: number = 4400 public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400 public connectionTimeout: number = 4400
public publishTimeout: number = 4400
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
@@ -34,6 +35,7 @@ export class AbstractRelay {
private incomingMessageQueue = new Queue<string>() private incomingMessageQueue = new Queue<string>()
private queueRunning = false private queueRunning = false
private challenge: string | undefined private challenge: string | undefined
private authPromise: Promise<string> | undefined
private serial: number = 0 private serial: number = 0
private verifyEvent: Nostr['verifyEvent'] private verifyEvent: Nostr['verifyEvent']
@@ -76,6 +78,7 @@ export class AbstractRelay {
if (this.connectionPromise) return this.connectionPromise if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined this.challenge = undefined
this.authPromise = undefined
this.connectionPromise = new Promise((resolve, reject) => { this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => { this.connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out') reject('connection timed out')
@@ -198,9 +201,12 @@ export class AbstractRelay {
const ok: boolean = data[2] const ok: boolean = data[2]
const reason: string = data[3] const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ok) ep.resolve(reason) if (ep) {
else ep.reject(new Error(reason)) clearTimeout(ep.timeout)
this.openEventPublishes.delete(id) if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
}
return return
} }
case 'CLOSED': { case 'CLOSED': {
@@ -216,6 +222,7 @@ export class AbstractRelay {
return return
case 'AUTH': { case 'AUTH': {
this.challenge = data[1] as string this.challenge = data[1] as string
this.authPromise = undefined
this._onauth?.(data[1] as string) this._onauth?.(data[1] as string)
return return
} }
@@ -235,17 +242,32 @@ export class AbstractRelay {
public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> { public async auth(signAuthEvent: (evt: EventTemplate) => Promise<VerifiedEvent>): Promise<string> {
if (!this.challenge) throw new Error("can't perform auth, no challenge was received") if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
if (this.authPromise) return this.authPromise
const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge)) const evt = await signAuthEvent(makeAuthEvent(this.url, this.challenge))
const ret = new Promise<string>((resolve, reject) => { this.authPromise = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(evt.id, { resolve, reject }) const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(evt.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('auth timed out'))
this.openEventPublishes.delete(evt.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
}) })
this.send('["AUTH",' + JSON.stringify(evt) + ']') this.send('["AUTH",' + JSON.stringify(evt) + ']')
return ret return this.authPromise
} }
public async publish(event: Event): Promise<string> { public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => { const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(event.id, { resolve, reject }) const timeout = setTimeout(() => {
const ep = this.openEventPublishes.get(event.id) as EventPublishResolver
if (ep) {
ep.reject(new Error('publish timed out'))
this.openEventPublishes.delete(event.id)
}
}, this.publishTimeout)
this.openEventPublishes.set(event.id, { resolve, reject, timeout })
}) })
this.send('["EVENT",' + JSON.stringify(event) + ']') this.send('["EVENT",' + JSON.stringify(event) + ']')
return ret return ret
@@ -261,15 +283,21 @@ export class AbstractRelay {
return ret return ret
} }
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription { public subscribe(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
const subscription = this.prepareSubscription(filters, params) const subscription = this.prepareSubscription(filters, params)
subscription.fire() subscription.fire()
return subscription return subscription
} }
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription { public prepareSubscription(
filters: Filter[],
params: Partial<SubscriptionParams> & { label?: string; id?: string },
): Subscription {
this.serial++ this.serial++
const id = params.id || 'sub:' + this.serial const id = params.id || (params.label ? params.label + ':' : 'sub:') + this.serial
const subscription = new Subscription(this, id, filters, params) const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription) this.openSubs.set(id, subscription)
return subscription return subscription
@@ -371,4 +399,5 @@ export type CountResolver = {
export type EventPublishResolver = { export type EventPublishResolver = {
resolve: (reason: string) => void resolve: (reason: string) => void
reject: (err: Error) => void reject: (err: Error) => void
timeout: ReturnType<typeof setTimeout>
} }

View File

@@ -1,5 +1,4 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { sortEvents } from './core.ts' import { sortEvents } from './core.ts'
test('sortEvents', () => { test('sortEvents', () => {

View File

@@ -215,6 +215,16 @@ describe('Filter', () => {
expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4) expect(getFilterLimit({ kinds: [0, 3], authors: ['alex', 'fiatjaf'] })).toEqual(4)
}) })
test('should handle parameterized replaceable events', () => {
expect(getFilterLimit({ kinds: [30078], authors: ['alex'] })).toEqual(Infinity)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto'] })).toEqual(1)
expect(getFilterLimit({ kinds: [30078], authors: ['alex'], '#d': ['ditto', 'soapbox'] })).toEqual(2)
expect(getFilterLimit({ kinds: [30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] })).toEqual(4)
expect(
getFilterLimit({ kinds: [30000, 30078], authors: ['alex', 'fiatjaf'], '#d': ['ditto', 'soapbox'] }),
).toEqual(8)
})
test('should return Infinity for authors with regular kinds', () => { test('should return Infinity for authors with regular kinds', () => {
expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity) expect(getFilterLimit({ kinds: [1], authors: ['alex'] })).toEqual(Infinity)
}) })

View File

@@ -1,5 +1,5 @@
import { Event } from './core.ts' import { Event } from './core.ts'
import { isReplaceableKind } from './kinds.ts' import { isAddressableKind, isReplaceableKind } from './kinds.ts'
export type Filter = { export type Filter = {
ids?: string[] ids?: string[]
@@ -72,7 +72,10 @@ export function mergeFilters(...filters: Filter[]): Filter {
return result return result
} }
/** Calculate the intrinsic limit of a filter. This function may return `Infinity`. */ /**
* Calculate the intrinsic limit of a filter.
* This function returns a positive integer, or `Infinity` if there is no intrinsic limit.
*/
export function getFilterLimit(filter: Filter): number { export function getFilterLimit(filter: Filter): number {
if (filter.ids && !filter.ids.length) return 0 if (filter.ids && !filter.ids.length) return 0
if (filter.kinds && !filter.kinds.length) return 0 if (filter.kinds && !filter.kinds.length) return 0
@@ -83,10 +86,20 @@ export function getFilterLimit(filter: Filter): number {
} }
return Math.min( return Math.min(
// The `limit` property creates an artificial limit.
Math.max(0, filter.limit ?? Infinity), Math.max(0, filter.limit ?? Infinity),
// There can only be one event per `id`.
filter.ids?.length ?? Infinity, filter.ids?.length ?? Infinity,
// Replaceable events are limited by the number of authors and kinds.
filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind)) filter.authors?.length && filter.kinds?.every(kind => isReplaceableKind(kind))
? filter.authors.length * filter.kinds.length ? filter.authors.length * filter.kinds.length
: Infinity, : Infinity,
// Parameterized replaceable events are limited by the number of authors, kinds, and "d" tags.
filter.authors?.length && filter.kinds?.every(kind => isAddressableKind(kind)) && filter['#d']?.length
? filter.authors.length * filter.kinds.length * filter['#d'].length
: Infinity,
) )
} }

View File

@@ -9,6 +9,7 @@ export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts' export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.ts' export * as nip11 from './nip11.ts'
export * as nip13 from './nip13.ts' export * as nip13 from './nip13.ts'
export * as nip17 from './nip17.ts'
export * as nip18 from './nip18.ts' export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts' export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts' export * as nip21 from './nip21.ts'
@@ -20,7 +21,9 @@ export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts' export * as nip42 from './nip42.ts'
export * as nip44 from './nip44.ts' export * as nip44 from './nip44.ts'
export * as nip47 from './nip47.ts' export * as nip47 from './nip47.ts'
export * as nip54 from './nip54.ts'
export * as nip57 from './nip57.ts' export * as nip57 from './nip57.ts'
export * as nip59 from './nip59.ts'
export * as nip98 from './nip98.ts' export * as nip98 from './nip98.ts'
export * as kinds from './kinds.ts' export * as kinds from './kinds.ts'

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.3.2", "version": "2.12.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",
@@ -20,6 +20,7 @@
"./nip10": "./nip10.ts", "./nip10": "./nip10.ts",
"./nip11": "./nip11.ts", "./nip11": "./nip11.ts",
"./nip13": "./nip13.ts", "./nip13": "./nip13.ts",
"./nip17": "./nip17.ts",
"./nip18": "./nip18.ts", "./nip18": "./nip18.ts",
"./nip19": "./nip19.ts", "./nip19": "./nip19.ts",
"./nip21": "./nip21.ts", "./nip21": "./nip21.ts",
@@ -33,7 +34,10 @@
"./nip44": "./nip44.ts", "./nip44": "./nip44.ts",
"./nip46": "./nip46.ts", "./nip46": "./nip46.ts",
"./nip49": "./nip49.ts", "./nip49": "./nip49.ts",
"./nip54": "./nip54.ts",
"./nip57": "./nip57.ts", "./nip57": "./nip57.ts",
"./nip58": "./nip58.ts",
"./nip59": "./nip59.ts",
"./nip75": "./nip75.ts", "./nip75": "./nip75.ts",
"./nip94": "./nip94.ts", "./nip94": "./nip94.ts",
"./nip96": "./nip96.ts", "./nip96": "./nip96.ts",
@@ -42,4 +46,4 @@
"./fakejson": "./fakejson.ts", "./fakejson": "./fakejson.ts",
"./utils": "./utils.ts" "./utils": "./utils.ts"
} }
} }

View File

@@ -13,6 +13,9 @@ test-only file:
publish: build publish: build
npm publish npm publish
perl -i -0pe "s/},\n \"optionalDependencies\": {\n/,/" package.json
jsr publish --allow-dirty
git checkout -- package.json
format: format:
eslint --ext .ts --fix *.ts eslint --ext .ts --fix *.ts

View File

@@ -1,5 +1,6 @@
import { test, expect } from 'bun:test' import { expect, test } from 'bun:test'
import { classifyKind } from './kinds.ts' import { classifyKind, isKind, Repost, ShortTextNote } from './kinds.ts'
import { finalizeEvent, generateSecretKey } from './pure.ts'
test('kind classification', () => { test('kind classification', () => {
expect(classifyKind(1)).toBe('regular') expect(classifyKind(1)).toBe('regular')
@@ -19,3 +20,22 @@ test('kind classification', () => {
expect(classifyKind(40000)).toBe('unknown') expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown') expect(classifyKind(255)).toBe('unknown')
}) })
test('kind type guard', () => {
const privateKey = generateSecretKey()
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
expect(isKind(repostedEvent, ShortTextNote)).toBeTrue()
expect(isKind(repostedEvent, Repost)).toBeFalse()
})

View File

@@ -1,3 +1,5 @@
import { NostrEvent, validateEvent } from './pure.ts'
/** Events are **regular**, which means they're all expected to be stored by relays. */ /** Events are **regular**, which means they're all expected to be stored by relays. */
export function isRegularKind(kind: number): boolean { export function isRegularKind(kind: number): boolean {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind) return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
@@ -13,11 +15,14 @@ export function isEphemeralKind(kind: number): boolean {
return 20000 <= kind && kind < 30000 return 20000 <= kind && kind < 30000
} }
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */ /** Events are **addressable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
export function isParameterizedReplaceableKind(kind: number): boolean { export function isAddressableKind(kind: number): boolean {
return 30000 <= kind && kind < 40000 return 30000 <= kind && kind < 40000
} }
/** @deprecated use isAddressableKind instead */
export const isParameterizedReplaceableKind = isAddressableKind
/** Classification of the event kind. */ /** Classification of the event kind. */
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown' export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
@@ -26,81 +31,166 @@ export function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular' if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable' if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral' if (isEphemeralKind(kind)) return 'ephemeral'
if (isParameterizedReplaceableKind(kind)) return 'parameterized' if (isAddressableKind(kind)) return 'parameterized'
return 'unknown' return 'unknown'
} }
export function isKind<T extends number>(event: unknown, kind: T | Array<T>): event is NostrEvent & { kind: T } {
const kindAsArray: number[] = kind instanceof Array ? kind : [kind]
return (validateEvent(event) && kindAsArray.includes(event.kind)) || false
}
export const Metadata = 0 export const Metadata = 0
export type Metadata = typeof Metadata
export const ShortTextNote = 1 export const ShortTextNote = 1
export type ShortTextNote = typeof ShortTextNote
export const RecommendRelay = 2 export const RecommendRelay = 2
export type RecommendRelay = typeof RecommendRelay
export const Contacts = 3 export const Contacts = 3
export type Contacts = typeof Contacts
export const EncryptedDirectMessage = 4 export const EncryptedDirectMessage = 4
export const EncryptedDirectMessages = 4 export type EncryptedDirectMessage = typeof EncryptedDirectMessage
export const EventDeletion = 5 export const EventDeletion = 5
export type EventDeletion = typeof EventDeletion
export const Repost = 6 export const Repost = 6
export type Repost = typeof Repost
export const Reaction = 7 export const Reaction = 7
export type Reaction = typeof Reaction
export const BadgeAward = 8 export const BadgeAward = 8
export type BadgeAward = typeof BadgeAward
export const Seal = 13
export type Seal = typeof Seal
export const PrivateDirectMessage = 14
export type PrivateDirectMessage = typeof PrivateDirectMessage
export const GenericRepost = 16 export const GenericRepost = 16
export type GenericRepost = typeof GenericRepost
export const ChannelCreation = 40 export const ChannelCreation = 40
export type ChannelCreation = typeof ChannelCreation
export const ChannelMetadata = 41 export const ChannelMetadata = 41
export type ChannelMetadata = typeof ChannelMetadata
export const ChannelMessage = 42 export const ChannelMessage = 42
export type ChannelMessage = typeof ChannelMessage
export const ChannelHideMessage = 43 export const ChannelHideMessage = 43
export type ChannelHideMessage = typeof ChannelHideMessage
export const ChannelMuteUser = 44 export const ChannelMuteUser = 44
export type ChannelMuteUser = typeof ChannelMuteUser
export const OpenTimestamps = 1040 export const OpenTimestamps = 1040
export type OpenTimestamps = typeof OpenTimestamps
export const GiftWrap = 1059
export type GiftWrap = typeof GiftWrap
export const FileMetadata = 1063 export const FileMetadata = 1063
export type FileMetadata = typeof FileMetadata
export const LiveChatMessage = 1311 export const LiveChatMessage = 1311
export type LiveChatMessage = typeof LiveChatMessage
export const ProblemTracker = 1971 export const ProblemTracker = 1971
export type ProblemTracker = typeof ProblemTracker
export const Report = 1984 export const Report = 1984
export type Report = typeof Report
export const Reporting = 1984 export const Reporting = 1984
export type Reporting = typeof Reporting
export const Label = 1985 export const Label = 1985
export type Label = typeof Label
export const CommunityPostApproval = 4550 export const CommunityPostApproval = 4550
export type CommunityPostApproval = typeof CommunityPostApproval
export const JobRequest = 5999 export const JobRequest = 5999
export type JobRequest = typeof JobRequest
export const JobResult = 6999 export const JobResult = 6999
export type JobResult = typeof JobResult
export const JobFeedback = 7000 export const JobFeedback = 7000
export type JobFeedback = typeof JobFeedback
export const ZapGoal = 9041 export const ZapGoal = 9041
export type ZapGoal = typeof ZapGoal
export const ZapRequest = 9734 export const ZapRequest = 9734
export type ZapRequest = typeof ZapRequest
export const Zap = 9735 export const Zap = 9735
export type Zap = typeof Zap
export const Highlights = 9802 export const Highlights = 9802
export type Highlights = typeof Highlights
export const Mutelist = 10000 export const Mutelist = 10000
export type Mutelist = typeof Mutelist
export const Pinlist = 10001 export const Pinlist = 10001
export type Pinlist = typeof Pinlist
export const RelayList = 10002 export const RelayList = 10002
export type RelayList = typeof RelayList
export const BookmarkList = 10003 export const BookmarkList = 10003
export type BookmarkList = typeof BookmarkList
export const CommunitiesList = 10004 export const CommunitiesList = 10004
export type CommunitiesList = typeof CommunitiesList
export const PublicChatsList = 10005 export const PublicChatsList = 10005
export type PublicChatsList = typeof PublicChatsList
export const BlockedRelaysList = 10006 export const BlockedRelaysList = 10006
export type BlockedRelaysList = typeof BlockedRelaysList
export const SearchRelaysList = 10007 export const SearchRelaysList = 10007
export type SearchRelaysList = typeof SearchRelaysList
export const InterestsList = 10015 export const InterestsList = 10015
export type InterestsList = typeof InterestsList
export const UserEmojiList = 10030 export const UserEmojiList = 10030
export type UserEmojiList = typeof UserEmojiList
export const DirectMessageRelaysList = 10050
export type DirectMessageRelaysList = typeof DirectMessageRelaysList
export const FileServerPreference = 10096 export const FileServerPreference = 10096
export type FileServerPreference = typeof FileServerPreference
export const NWCWalletInfo = 13194 export const NWCWalletInfo = 13194
export type NWCWalletInfo = typeof NWCWalletInfo
export const LightningPubRPC = 21000 export const LightningPubRPC = 21000
export type LightningPubRPC = typeof LightningPubRPC
export const ClientAuth = 22242 export const ClientAuth = 22242
export type ClientAuth = typeof ClientAuth
export const NWCWalletRequest = 23194 export const NWCWalletRequest = 23194
export type NWCWalletRequest = typeof NWCWalletRequest
export const NWCWalletResponse = 23195 export const NWCWalletResponse = 23195
export type NWCWalletResponse = typeof NWCWalletResponse
export const NostrConnect = 24133 export const NostrConnect = 24133
export type NostrConnect = typeof NostrConnect
export const HTTPAuth = 27235 export const HTTPAuth = 27235
export type HTTPAuth = typeof HTTPAuth
export const Followsets = 30000 export const Followsets = 30000
export type Followsets = typeof Followsets
export const Genericlists = 30001 export const Genericlists = 30001
export type Genericlists = typeof Genericlists
export const Relaysets = 30002 export const Relaysets = 30002
export type Relaysets = typeof Relaysets
export const Bookmarksets = 30003 export const Bookmarksets = 30003
export type Bookmarksets = typeof Bookmarksets
export const Curationsets = 30004 export const Curationsets = 30004
export type Curationsets = typeof Curationsets
export const ProfileBadges = 30008 export const ProfileBadges = 30008
export type ProfileBadges = typeof ProfileBadges
export const BadgeDefinition = 30009 export const BadgeDefinition = 30009
export type BadgeDefinition = typeof BadgeDefinition
export const Interestsets = 30015 export const Interestsets = 30015
export type Interestsets = typeof Interestsets
export const CreateOrUpdateStall = 30017 export const CreateOrUpdateStall = 30017
export type CreateOrUpdateStall = typeof CreateOrUpdateStall
export const CreateOrUpdateProduct = 30018 export const CreateOrUpdateProduct = 30018
export type CreateOrUpdateProduct = typeof CreateOrUpdateProduct
export const LongFormArticle = 30023 export const LongFormArticle = 30023
export type LongFormArticle = typeof LongFormArticle
export const DraftLong = 30024 export const DraftLong = 30024
export type DraftLong = typeof DraftLong
export const Emojisets = 30030 export const Emojisets = 30030
export type Emojisets = typeof Emojisets
export const Application = 30078 export const Application = 30078
export type Application = typeof Application
export const LiveEvent = 30311 export const LiveEvent = 30311
export type LiveEvent = typeof LiveEvent
export const UserStatuses = 30315 export const UserStatuses = 30315
export type UserStatuses = typeof UserStatuses
export const ClassifiedListing = 30402 export const ClassifiedListing = 30402
export type ClassifiedListing = typeof ClassifiedListing
export const DraftClassifiedListing = 30403 export const DraftClassifiedListing = 30403
export type DraftClassifiedListing = typeof DraftClassifiedListing
export const Date = 31922 export const Date = 31922
export type Date = typeof Date
export const Time = 31923 export const Time = 31923
export type Time = typeof Time
export const Calendar = 31924 export const Calendar = 31924
export type Calendar = typeof Calendar
export const CalendarEventRSVP = 31925 export const CalendarEventRSVP = 31925
export type CalendarEventRSVP = typeof CalendarEventRSVP
export const Handlerrecommendation = 31989 export const Handlerrecommendation = 31989
export type Handlerrecommendation = typeof Handlerrecommendation
export const Handlerinformation = 31990 export const Handlerinformation = 31990
export type Handlerinformation = typeof Handlerinformation
export const CommunityDefinition = 34550 export const CommunityDefinition = 34550
export type CommunityDefinition = typeof CommunityDefinition

View File

@@ -5,7 +5,7 @@ import { base64 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts' import { utf8Decoder, utf8Encoder } from './utils.ts'
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> { export function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key) const normalizedKey = getNormalizedX(key)
@@ -21,7 +21,7 @@ export async function encrypt(secretKey: string | Uint8Array, pubkey: string, te
return `${ctb64}?iv=${ivb64}` return `${ctb64}?iv=${ivb64}`
} }
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> { export function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): string {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
let [ctb64, ivb64] = data.split('?iv=') let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey) let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)

View File

@@ -1,18 +1,44 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, queryProfile } from './nip05.ts' import { useFetchImplementation, queryProfile, NIP05_REGEX, isNip05 } from './nip05.ts'
test('validate NIP05_REGEX', () => {
expect(NIP05_REGEX.test('_@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('bob@bob.com.br')).toBeTrue()
expect(NIP05_REGEX.test('b&b@bob.com.br')).toBeFalse()
expect('b&b@bob.com.br'.match(NIP05_REGEX)).toBeNull()
expect(Array.from('bob@bob.com.br'.match(NIP05_REGEX) || [])).toEqual(['bob@bob.com.br', 'bob', 'bob.com.br', '.br'])
expect(isNip05('bob@bob.com.br')).toBeTrue()
expect(isNip05('b&b@bob.com.br')).toBeFalse()
})
test('fetch nip05 profiles', async () => { test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch) const fetchStub = async (url: string) => ({
status: 200,
async json() {
return {
'https://compile-error.net/.well-known/nostr.json?name=_': {
names: { _: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc' },
},
'https://fiatjaf.com/.well-known/nostr.json?name=_': {
names: { _: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' },
relays: {
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d': [
'wss://pyramid.fiatjaf.com',
'wss://nos.lol',
],
},
},
}[url]
},
})
let p1 = await queryProfile('jb55.com') useFetchImplementation(fetchStub)
expect(p1!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245')
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com') let p2 = await queryProfile('compile-error.net')
expect(p2!.pubkey).toEqual('32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245') expect(p2!.pubkey).toEqual('2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc')
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com') let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d') expect(p3!.pubkey).toEqual('3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d')

View File

@@ -1,5 +1,7 @@
import { ProfilePointer } from './nip19.ts' import { ProfilePointer } from './nip19.ts'
export type Nip05 = `${string}@${string}`
/** /**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise. * NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
* *
@@ -8,21 +10,28 @@ import { ProfilePointer } from './nip19.ts'
* - 2: domain * - 2: domain
*/ */
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/ export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w_-]+(\.[\w_-]+)+)$/
export const isNip05 = (value?: string | null): value is Nip05 => NIP05_REGEX.test(value || '')
var _fetch: any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let _fetch: any
try { try {
_fetch = fetch _fetch = fetch
} catch {} } catch (_) {
null
}
export function useFetchImplementation(fetchImplementation: any) { export function useFetchImplementation(fetchImplementation: unknown) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> { export async function searchDomain(domain: string, query = ''): Promise<{ [name: string]: string }> {
try { try {
const url = `https://${domain}/.well-known/nostr.json?name=${query}` const url = `https://${domain}/.well-known/nostr.json?name=${query}`
const res = await _fetch(url, { redirect: 'error' }) const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json() const json = await res.json()
return json.names return json.names
} catch (_) { } catch (_) {
@@ -34,20 +43,24 @@ export async function queryProfile(fullname: string): Promise<ProfilePointer | n
const match = fullname.match(NIP05_REGEX) const match = fullname.match(NIP05_REGEX)
if (!match) return null if (!match) return null
const [_, name = '_', domain] = match const [, name = '_', domain] = match
try { try {
const url = `https://${domain}/.well-known/nostr.json?name=${name}` const url = `https://${domain}/.well-known/nostr.json?name=${name}`
const res = await (await _fetch(url, { redirect: 'error' })).json() const res = await _fetch(url, { redirect: 'manual' })
if (res.status !== 200) {
throw Error('Wrong response code')
}
const json = await res.json()
let pubkey = res.names[name] const pubkey = json.names[name]
return pubkey ? { pubkey, relays: res.relays?.[pubkey] } : null return pubkey ? { pubkey, relays: json.relays?.[pubkey] } : null
} catch (_e) { } catch (_e) {
return null return null
} }
} }
export async function isValid(pubkey: string, nip05: string): Promise<boolean> { export async function isValid(pubkey: string, nip05: Nip05): Promise<boolean> {
let res = await queryProfile(nip05) const res = await queryProfile(nip05)
return res ? res.pubkey === pubkey : false return res ? res.pubkey === pubkey : false
} }

View File

@@ -5,38 +5,39 @@ import {
extendedKeysFromSeedWords, extendedKeysFromSeedWords,
accountFromExtendedKey, accountFromExtendedKey,
} from './nip06.ts' } from './nip06.ts'
import { hexToBytes } from '@noble/hashes/utils'
test('generate private key from a mnemonic', async () => { test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic) const privateKey = privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2') expect(privateKey).toEqual(hexToBytes('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'))
}) })
test('generate private key for account 1 from a mnemonic', async () => { test('generate private key for account 1 from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1) const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b') expect(privateKey).toEqual(hexToBytes('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b'))
}) })
test('generate private key from a mnemonic and passphrase', async () => { test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase) const privateKey = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4') expect(privateKey).toEqual(hexToBytes('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'))
}) })
test('generate private key for account 1 from a mnemonic and passphrase', async () => { test('generate private key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' const passphrase = '123'
const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1) const privateKey = privateKeyFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135') expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
}) })
test('generate private and public key for account 1 from a mnemonic and passphrase', async () => { test('generate private and public key for account 1 from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123' const passphrase = '123'
const { privateKey, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1) const { privateKey, publicKey } = accountFromSeedWords(mnemonic, passphrase, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135') expect(privateKey).toEqual(hexToBytes('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135'))
expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a') expect(publicKey).toEqual('13f55f4f01576570ea342eb7d2b611f9dc78f8dc601aeb512011e4e73b90cf0a')
}) })
@@ -63,7 +64,7 @@ test('generate account from extended private key', () => {
'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH' 'xprv9z78fizET65qsCaRr1MSutTSGk1fcKfSt1sBqmuWShtkjRJJ4WCKcSnha6EmgNzFSsyom3MWtydHyPtJtSLZQUtictVQtM2vkPcguh6TQCH'
const { privateKey, publicKey } = accountFromExtendedKey(xprv) const { privateKey, publicKey } = accountFromExtendedKey(xprv)
expect(privateKey).toBe('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731') expect(privateKey).toEqual(hexToBytes('5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731'))
expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f') expect(publicKey).toBe('e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f')
}) })

View File

@@ -5,11 +5,11 @@ import { HDKey } from '@scure/bip32'
const DERIVATION_PATH = `m/44'/1237'` const DERIVATION_PATH = `m/44'/1237'`
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string { export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): Uint8Array {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)) let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey let privateKey = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key') if (!privateKey) throw new Error('could not derive private key')
return bytesToHex(privateKey) return privateKey
} }
export function accountFromSeedWords( export function accountFromSeedWords(
@@ -17,14 +17,14 @@ export function accountFromSeedWords(
passphrase?: string, passphrase?: string,
accountIndex = 0, accountIndex = 0,
): { ): {
privateKey: string privateKey: Uint8Array
publicKey: string publicKey: string
} { } {
const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)) const root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`) const seed = root.derive(`${DERIVATION_PATH}/${accountIndex}'/0/0`)
const privateKey = bytesToHex(seed.privateKey!)
const publicKey = bytesToHex(seed.publicKey!.slice(1)) const publicKey = bytesToHex(seed.publicKey!.slice(1))
if (!privateKey && !publicKey) { const privateKey = seed.privateKey
if (!privateKey || !publicKey) {
throw new Error('could not derive key pair') throw new Error('could not derive key pair')
} }
return { privateKey, publicKey } return { privateKey, publicKey }
@@ -50,7 +50,7 @@ export function accountFromExtendedKey(
base58key: string, base58key: string,
accountIndex = 0, accountIndex = 0,
): { ): {
privateKey?: string privateKey?: Uint8Array
publicKey: string publicKey: string
} { } {
let extendedKey = HDKey.fromExtendedKey(base58key) let extendedKey = HDKey.fromExtendedKey(base58key)
@@ -59,7 +59,7 @@ export function accountFromExtendedKey(
let publicKey = bytesToHex(child.publicKey!.slice(1)) let publicKey = bytesToHex(child.publicKey!.slice(1))
if (!publicKey) throw new Error('could not derive public key') if (!publicKey) throw new Error('could not derive public key')
if (version === 'xprv') { if (version === 'xprv') {
let privateKey = bytesToHex(child.privateKey!) let privateKey = child.privateKey!
if (!privateKey) throw new Error('could not derive private key') if (!privateKey) throw new Error('could not derive private key')
return { privateKey, publicKey } return { privateKey, publicKey }
} }

View File

@@ -1,10 +1,8 @@
import { EventTemplate, NostrEvent } from './core.ts' import { EventTemplate, NostrEvent } from './core.ts'
import { RelayRecord } from './relay.ts'
export interface WindowNostr { export interface WindowNostr {
getPublicKey(): Promise<string> getPublicKey(): Promise<string>
signEvent(event: EventTemplate): Promise<NostrEvent> signEvent(event: EventTemplate): Promise<NostrEvent>
getRelays(): Promise<RelayRecord>
nip04?: { nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string> encrypt(pubkey: string, plaintext: string): Promise<string>
decrypt(pubkey: string, ciphertext: string): Promise<string> decrypt(pubkey: string, ciphertext: string): Promise<string>

View File

@@ -5,20 +5,21 @@ describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => { test('legacy + a lot of events', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'],
['e', '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'],
['e', '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'],
['e', '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -55,33 +56,80 @@ describe('parse NIP10-referenced events', () => {
relays: [], relays: [],
}, },
], ],
reply: { root: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d', id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: [], relays: [],
}, },
root: { reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [], relays: [],
}, },
}) })
}) })
test('legacy + 3 events', () => { test('modern', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], ['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['e', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', '', 'root'],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c', '', 'reply'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
},
],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: [],
},
reply: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('modern, inverted, author hint', () => {
let event = {
tags: [
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', 'wss://goiaba.com'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'],
['e', '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', '', 'reply'],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
'wss://banana.com',
'root',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
],
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'],
],
}
expect(parse(event)).toEqual({
quotes: [],
mentions: [
{
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [], relays: [],
}, },
], ],
@@ -96,98 +144,80 @@ describe('parse NIP10-referenced events', () => {
}, },
{ {
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0', pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [], relays: ['wss://banana.com', 'wss://goiaba.com'],
}, },
], ],
root: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: ['wss://banana.com', 'wss://goiaba.com'],
author: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
},
reply: { reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64', id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: [], relays: [],
}, },
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
}) })
}) })
test('legacy + 2 events', () => { test('1 event, relay hint from author', () => {
let event = { let event = {
tags: [ tags: [
['e', 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'], ['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', 'wss://banana.com'],
['e', 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'], [
['p', '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'], 'e',
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'], '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
['p', '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'], '',
'root',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [], mentions: [],
profiles: [ profiles: [
{
pubkey: '77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: [],
},
{ {
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec', pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [], relays: ['wss://banana.com'],
},
{
pubkey: '4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: [],
}, },
], ],
reply: { reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: [],
},
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
['e', '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'],
['p', '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'],
],
}
expect(parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: [],
},
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590', id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: [], relays: ['wss://banana.com'],
},
root: {
author: '534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: ['wss://banana.com'],
}, },
}) })
}) })
test('recommended + 1 event', () => { test('many p 1 reply', () => {
let event = { let event = {
tags: [ tags: [
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'], ['p', 'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda', 'wss://relay.mostr.pub'],
['e', 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', 'wss://relay.mostr.pub', 'reply'], ['p', '094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e', 'wss://relay.mostr.pub'],
['p', 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512', 'wss://relay.mostr.pub'],
['p', '44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e', 'wss://relay.mostr.pub'],
['p', '2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e', 'wss://relay.mostr.pub'],
['p', '003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6', 'wss://relay.mostr.pub'],
['p', 'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52', 'wss://relay.mostr.pub'],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'], ['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea'],
], ],
} }
expect(parse(event)).toEqual({ expect(parse(event)).toEqual({
quotes: [],
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
@@ -222,8 +252,13 @@ describe('parse NIP10-referenced events', () => {
reply: { reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d', id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'], relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
},
root: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub'],
author: 'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
}, },
root: undefined,
}) })
}) })
}) })

152
nip10.ts
View File

@@ -1,7 +1,7 @@
import type { Event } from './core.ts' import type { Event } from './core.ts'
import type { EventPointer, ProfilePointer } from './nip19.ts' import type { EventPointer, ProfilePointer } from './nip19.ts'
export type NIP10Result = { export function parse(event: Pick<Event, 'tags'>): {
/** /**
* Pointer to the root of the thread. * Pointer to the root of the thread.
*/ */
@@ -13,29 +13,80 @@ export type NIP10Result = {
reply: EventPointer | undefined reply: EventPointer | undefined
/** /**
* Pointers to events which may or may not be in the reply chain. * Pointers to events that may or may not be in the reply chain.
*/ */
mentions: EventPointer[] mentions: EventPointer[]
/**
* Pointers to events that were directly quoted.
*/
quotes: EventPointer[]
/** /**
* List of pubkeys that are involved in the thread in no particular order. * List of pubkeys that are involved in the thread in no particular order.
*/ */
profiles: ProfilePointer[] profiles: ProfilePointer[]
} } {
const result: ReturnType<typeof parse> = {
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined, reply: undefined,
root: undefined, root: undefined,
mentions: [], mentions: [],
profiles: [], profiles: [],
quotes: [],
} }
const eTags: string[][] = [] let maybeParent: EventPointer | undefined
let maybeRoot: EventPointer | undefined
for (let i = event.tags.length - 1; i >= 0; i--) {
const tag = event.tags[i]
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) { if (tag[0] === 'e' && tag[1]) {
eTags.push(tag) const [_, eTagEventId, eTagRelayUrl, eTagMarker, eTagAuthor] = tag as [
string,
string,
undefined | string,
undefined | string,
undefined | string,
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
author: eTagAuthor,
}
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (!maybeParent) {
maybeParent = eventPointer
} else {
maybeRoot = eventPointer
}
result.mentions.push(eventPointer)
continue
}
if (tag[0] === 'q' && tag[1]) {
const [_, eTagEventId, eTagRelayUrl] = tag as [string, string, undefined | string]
result.quotes.push({
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
})
} }
if (tag[0] === 'p' && tag[1]) { if (tag[0] === 'p' && tag[1]) {
@@ -43,49 +94,54 @@ export function parse(event: Pick<Event, 'tags'>): NIP10Result {
pubkey: tag[1], pubkey: tag[1],
relays: tag[2] ? [tag[2]] : [], relays: tag[2] ? [tag[2]] : [],
}) })
continue
} }
} }
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) { // get legacy (positional) markers, set reply to root and vice-versa if one of them is missing
const eTag = eTags[eTagIndex] if (!result.root) {
result.root = maybeRoot || maybeParent || result.reply
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [string, string, undefined | string, undefined | string]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : [],
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
} }
if (!result.reply) {
result.reply = maybeParent || result.root
}
// remove root and reply from mentions, inherit relay hints from authors if any
;[result.reply, result.root].forEach(ref => {
if (!ref) return
let idx = result.mentions.indexOf(ref)
if (idx !== -1) {
result.mentions.splice(idx, 1)
}
if (ref.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!?.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
result.mentions.forEach(ref => {
if (ref!.author) {
let author = result.profiles.find(p => p.pubkey === ref.author)
if (author && author.relays) {
if (!ref.relays) {
ref.relays = []
}
author.relays.forEach(url => {
if (ref.relays!.indexOf(url) === -1) ref.relays!.push(url)
})
author.relays = ref.relays
}
}
})
return result return result
} }

View File

@@ -10,7 +10,9 @@ describe('requesting relay as for NIP11', () => {
const info = await fetchRelayInformation('wss://nos.lol') const info = await fetchRelayInformation('wss://nos.lol')
expect(info.name).toEqual('nos.lol') expect(info.name).toEqual('nos.lol')
expect(info.description).toContain('Generally accepts notes, except spammy ones.') expect(info.description).toContain('Generally accepts notes, except spammy ones.')
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40]) expect(info.supported_nips).toContain(1)
expect(info.supported_nips).toContain(11)
expect(info.supported_nips).toContain(70)
expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git') expect(info.software).toEqual('git+https://github.com/hoytech/strfry.git')
}) })
}) })

View File

@@ -126,7 +126,7 @@ export interface Limitations {
restricted_writes: boolean restricted_writes: boolean
} }
interface RetentionDetails { export interface RetentionDetails {
kinds: (number | number[])[] kinds: (number | number[])[]
time?: number | null time?: number | null
count?: number | null count?: number | null

View File

@@ -2,9 +2,14 @@ import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts' import { getPow, minePow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => { test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358' ;[
const difficulty = getPow(id) ['000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358', 21],
expect(difficulty).toEqual(21) ['6bf5b4f434813c64b523d2b0e6efe18f3bd0cbbd0a5effd8ece9e00fd2531996', 1],
['00003479309ecdb46b1c04ce129d2709378518588bed6776e60474ebde3159ae', 18],
['01a76167d41add96be4959d9e618b7a35f26551d62c43c11e5e64094c6b53c83', 7],
['ac4f44bae06a45ebe88cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 0],
['0000000000000000006cfbd3c66358750159650a26c0d79e8ccaa92457fca4f6', 73],
].forEach(([id, diff]) => expect(getPow(id as string)).toEqual(diff as number))
}) })
test('mines POW for an event', async () => { test('mines POW for an event', async () => {

View File

@@ -1,15 +1,19 @@
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts' import { bytesToHex } from '@noble/hashes/utils'
import { type UnsignedEvent, type Event } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
/** Get POW difficulty from a Nostr hex ID. */ /** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number { export function getPow(hex: string): number {
let count = 0 let count = 0
for (let i = 0; i < hex.length; i++) { for (let i = 0; i < 64; i += 8) {
const nibble = parseInt(hex[i], 16) const nibble = parseInt(hex.substring(i, i + 8), 16)
if (nibble === 0) { if (nibble === 0) {
count += 4 count += 32
} else { } else {
count += Math.clz32(nibble) - 28 count += Math.clz32(nibble)
break break
} }
} }
@@ -20,8 +24,6 @@ export function getPow(hex: string): number {
/** /**
* Mine an event with the desired POW. This function mutates the event. * Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread. * Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
*
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
*/ */
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> { export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
let count = 0 let count = 0
@@ -41,7 +43,7 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
tag[1] = (++count).toString() tag[1] = (++count).toString()
event.id = getEventHash(event) event.id = fastEventHash(event)
if (getPow(event.id) >= difficulty) { if (getPow(event.id) >= difficulty) {
break break
@@ -50,3 +52,9 @@ export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event
return event return event
} }
export function fastEventHash(evt: UnsignedEvent): string {
return bytesToHex(
sha256(utf8Encoder.encode(JSON.stringify([0, evt.pubkey, evt.created_at, evt.kind, evt.tags, evt.content]))),
)
}

97
nip17.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import { test, expect } from 'bun:test'
import { getPublicKey } from './pure.ts'
import { decode } from './nip19.ts'
import { wrapEvent, wrapManyEvents, unwrapEvent } from './nip17.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const sk1 = hexToBytes('f09ac9b695d0a4c6daa418fe95b977eea20f54d9545592bc36a4f9e14f3eb840')
const sk2 = hexToBytes('5393a825e5892d8e18d4a5ea61ced105e8bb2a106f42876be3a40522e0b13747')
const recipients = [
{ publicKey: getPublicKey(sk1), relayUrl: 'wss://relay1.com' },
{ publicKey: getPublicKey(sk2) }, // No relay URL for this recipient
]
const message = 'Hello, this is a direct message!'
const conversationTitle = 'Private Group Conversation' // Optional
const replyTo = { eventId: 'previousEventId123' } // Optional, for replies
const wrappedEvent = wrapEvent(senderPrivateKey, recipients[0], message, conversationTitle, replyTo)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
[Symbol('verified')]: true,
}
expect(wrappedEvent.kind).toEqual(expected.kind)
expect(wrappedEvent.tags).toEqual(expected.tags)
})
test('wrapManyEvents', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729560014,
tags: [['p', '36f7288c84d85ca6aa189dc3581d63ce140b7eeef5ae759421c5b5a3627312db']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(senderPrivateKey, recipients, message, conversationTitle, replyTo)
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 14,
content: 'Hello, this is a direct message!',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [
['p', 'b60849e5aae4113b236f9deb34f6f85605b4c53930651309a0d60c7ea721aad0', 'wss://relay1.com'],
['e', 'previousEventId123', '', 'reply'],
['subject', 'Private Group Conversation'],
],
}
const result = unwrapEvent(wrappedEvent, sk1)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})

77
nip17.ts Normal file
View File

@@ -0,0 +1,77 @@
import { PrivateDirectMessage } from './kinds.ts'
import { EventTemplate, NostrEvent, getPublicKey } from './pure.ts'
import * as nip59 from './nip59.ts'
type Recipient = {
publicKey: string
relayUrl?: string
}
type ReplyTo = {
eventId: string
relayUrl?: string
}
function createEvent(
recipients: Recipient | Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): EventTemplate {
const baseEvent: EventTemplate = {
created_at: Math.ceil(Date.now() / 1000),
kind: PrivateDirectMessage,
tags: [],
content: message,
}
const recipientsArray = Array.isArray(recipients) ? recipients : [recipients]
recipientsArray.forEach(({ publicKey, relayUrl }) => {
baseEvent.tags.push(relayUrl ? ['p', publicKey, relayUrl] : ['p', publicKey])
})
if (replyTo) {
baseEvent.tags.push(['e', replyTo.eventId, replyTo.relayUrl || '', 'reply'])
}
if (conversationTitle) {
baseEvent.tags.push(['subject', conversationTitle])
}
return baseEvent
}
export function wrapEvent(
senderPrivateKey: Uint8Array,
recipient: Recipient,
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent {
const event = createEvent(recipient, message, conversationTitle, replyTo)
return nip59.wrapEvent(event, senderPrivateKey, recipient.publicKey)
}
export function wrapManyEvents(
senderPrivateKey: Uint8Array,
recipients: Recipient[],
message: string,
conversationTitle?: string,
replyTo?: ReplyTo,
): NostrEvent[] {
if (!recipients || recipients.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
// wrap the event for the sender and then for each recipient
return [{ publicKey: senderPublicKey }, ...recipients].map(recipient =>
wrapEvent(senderPrivateKey, recipient, message, conversationTitle, replyTo),
)
}
export const unwrapEvent = nip59.unwrapEvent
export const unwrapManyEvents = nip59.unwrapManyEvents

View File

@@ -1,7 +1,7 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils' import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts' import { EventTemplate, finalizeEvent, getPublicKey } from './pure.ts'
import { Repost, ShortTextNote } from './kinds.ts' import { GenericRepost, Repost, ShortTextNote, BadgeDefinition as BadgeDefinitionKind } from './kinds.ts'
import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts' import { finishRepostEvent, getRepostedEventPointer, getRepostedEvent } from './nip18.ts'
import { buildEvent } from './test-helpers.ts' import { buildEvent } from './test-helpers.ts'
@@ -86,6 +86,51 @@ describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () =>
}) })
}) })
describe('GenericRepost', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const eventTemplate: EventTemplate = {
content: '',
created_at: 1617932114,
kind: BadgeDefinitionKind,
tags: [
['d', 'badge-id'],
['name', 'Badge Name'],
['description', 'Badge Description'],
['image', 'https://example.com/badge.png', '1024x1024'],
['thumb', 'https://example.com/thumb.png', '100x100'],
['thumb', 'https://example.com/thumb2.png', '200x200'],
],
}
const repostedEvent = finalizeEvent(eventTemplate, privateKey)
test('should create a generic reposted event', () => {
const template = { created_at: 1617932115 }
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.kind).toEqual(GenericRepost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey],
['k', '30009'],
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
})
describe('getRepostedEventPointer', () => { describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => { test('should parse an event with only an `e` tag', () => {
const event = buildEvent({ const event = buildEvent({
@@ -100,3 +145,26 @@ describe('getRepostedEventPointer', () => {
expect(repostedEventPointer!.relays).toEqual([relayUrl]) expect(repostedEventPointer!.relays).toEqual([relayUrl])
}) })
}) })
describe('finishRepostEvent', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
test('should create an event with empty content if the reposted event is protected', () => {
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [['-']],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
const template = {
created_at: 1617932115,
}
const event = finishRepostEvent(template, repostedEvent, relayUrl, privateKey)
expect(event.content).toBe('')
})
})

View File

@@ -1,6 +1,6 @@
import { Event, finalizeEvent, verifyEvent } from './pure.ts' import { GenericRepost, Repost, ShortTextNote } from './kinds.ts'
import { Repost } from './kinds.ts'
import { EventPointer } from './nip19.ts' import { EventPointer } from './nip19.ts'
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
export type RepostEventTemplate = { export type RepostEventTemplate = {
/** /**
@@ -25,11 +25,20 @@ export function finishRepostEvent(
relayUrl: string, relayUrl: string,
privateKey: Uint8Array, privateKey: Uint8Array,
): Event { ): Event {
let kind: Repost | GenericRepost
const tags = [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]]
if (reposted.kind === ShortTextNote) {
kind = Repost
} else {
kind = GenericRepost
tags.push(['k', String(reposted.kind)])
}
return finalizeEvent( return finalizeEvent(
{ {
kind: Repost, kind,
tags: [...(t.tags ?? []), ['e', reposted.id, relayUrl], ['p', reposted.pubkey]], tags,
content: t.content === '' ? '' : JSON.stringify(reposted), content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
created_at: t.created_at, created_at: t.created_at,
}, },
privateKey, privateKey,
@@ -37,7 +46,7 @@ export function finishRepostEvent(
} }
export function getRepostedEventPointer(event: Event): undefined | EventPointer { export function getRepostedEventPointer(event: Event): undefined | EventPointer {
if (event.kind !== Repost) { if (![Repost, GenericRepost].includes(event.kind)) {
return undefined return undefined
} }

View File

@@ -1,16 +1,16 @@
import { test, expect } from 'bun:test' import { test, expect, describe } from 'bun:test'
import { generateSecretKey, getPublicKey } from './pure.ts' import { generateSecretKey, getPublicKey } from './pure.ts'
import { import {
decode, decode,
naddrEncode, naddrEncode,
nprofileEncode, nprofileEncode,
npubEncode, npubEncode,
nrelayEncode,
nsecEncode, nsecEncode,
neventEncode, neventEncode,
type AddressPointer, type AddressPointer,
type ProfilePointer, type ProfilePointer,
EventPointer, EventPointer,
NostrTypeGuard,
} from './nip19.ts' } from './nip19.ts'
test('encode and decode nsec', () => { test('encode and decode nsec', () => {
@@ -153,11 +153,134 @@ test('decode naddr from go-nostr with different TLV ordering', () => {
expect(pointer.identifier).toEqual('banana') expect(pointer.identifier).toEqual('banana')
}) })
test('encode and decode nrelay', () => { describe('NostrTypeGuard', () => {
let url = 'wss://relay.nostr.example' test('isNProfile', () => {
let nrelay = nrelayEncode(url) const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(nrelay).toMatch(/nrelay1\w+/)
let { type, data } = decode(nrelay) expect(is).toBeTrue()
expect(type).toEqual('nrelay') })
expect(data).toEqual(url)
test('isNProfile invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxãg')
expect(is).toBeFalse()
})
test('isNProfile with invalid nprofile', () => {
const is = NostrTypeGuard.isNProfile('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNEvent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9',
)
expect(is).toBeTrue()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent(
'nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8ãrnc9',
)
expect(is).toBeFalse()
})
test('isNEvent with invalid nevent', () => {
const is = NostrTypeGuard.isNEvent('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNAddr', () => {
const is = NostrTypeGuard.isNAddr(
'naddr1qqxnzdesxqmnxvpexqunzvpcqyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypve7elhmamff3sr5mgxxms4a0rppkmhmn7504h96pfcdkpplvl2jqcyqqq823cnmhuld',
)
expect(is).toBeTrue()
})
test('isNAddr with invalid nadress', () => {
const is = NostrTypeGuard.isNAddr('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNSec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeTrue()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juã')
expect(is).toBeFalse()
})
test('isNSec with invalid nsec', () => {
const is = NostrTypeGuard.isNSec('nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg')
expect(is).toBeFalse()
})
test('isNPub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeTrue()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzãsv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNPub with invalid npub', () => {
const is = NostrTypeGuard.isNPub('nsec1lqw6zqyanj9mz8gwhdam6tqge42vptz4zg93qsfej440xm5h5esqya0juv')
expect(is).toBeFalse()
})
test('isNote', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky')
expect(is).toBeTrue()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
test('isNote with invalid note', () => {
const is = NostrTypeGuard.isNote('npub1jz5mdljkmffmqjshpyjgqgrhdkuxd9ztzasv8xeh5q92fv33sjgqy4pats')
expect(is).toBeFalse()
})
test('isNcryptsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeTrue()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec(
'ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsã8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p',
)
expect(is).toBeFalse()
})
test('isNcryptsec with invalid ncrytpsec', () => {
const is = NostrTypeGuard.isNcryptsec('note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sçlreky')
expect(is).toBeFalse()
})
}) })

View File

@@ -3,6 +3,24 @@ import { bech32 } from '@scure/base'
import { utf8Decoder, utf8Encoder } from './utils.ts' import { utf8Decoder, utf8Encoder } from './utils.ts'
export type NProfile = `nprofile1${string}`
export type NEvent = `nevent1${string}`
export type NAddr = `naddr1${string}`
export type NSec = `nsec1${string}`
export type NPub = `npub1${string}`
export type Note = `note1${string}`
export type Ncryptsec = `ncryptsec1${string}`
export const NostrTypeGuard = {
isNProfile: (value?: string | null): value is NProfile => /^nprofile1[a-z\d]+$/.test(value || ''),
isNEvent: (value?: string | null): value is NEvent => /^nevent1[a-z\d]+$/.test(value || ''),
isNAddr: (value?: string | null): value is NAddr => /^naddr1[a-z\d]+$/.test(value || ''),
isNSec: (value?: string | null): value is NSec => /^nsec1[a-z\d]{58}$/.test(value || ''),
isNPub: (value?: string | null): value is NPub => /^npub1[a-z\d]{58}$/.test(value || ''),
isNote: (value?: string | null): value is Note => /^note1[a-z\d]+$/.test(value || ''),
isNcryptsec: (value?: string | null): value is Ncryptsec => /^ncryptsec1[a-z\d]+$/.test(value || ''),
}
export const Bech32MaxSize = 5000 export const Bech32MaxSize = 5000
/** /**
@@ -45,7 +63,6 @@ export type AddressPointer = {
type Prefixes = { type Prefixes = {
nprofile: ProfilePointer nprofile: ProfilePointer
nrelay: string
nevent: EventPointer nevent: EventPointer
naddr: AddressPointer naddr: AddressPointer
nsec: Uint8Array nsec: Uint8Array
@@ -62,6 +79,15 @@ export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P> [P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes] }[keyof Prefixes]
export function decodeNostrURI(nip19code: string): DecodeResult | { type: 'invalid'; data: null } {
try {
if (nip19code.startsWith('nostr:')) nip19code = nip19code.substring(6)
return decode(nip19code)
} catch (_err) {
return { type: 'invalid', data: null }
}
}
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix> export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult { export function decode(nip19: string): DecodeResult {
@@ -119,16 +145,6 @@ export function decode(nip19: string): DecodeResult {
} }
} }
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0]),
}
}
case 'nsec': case 'nsec':
return { type: prefix, data } return { type: prefix, data }
@@ -158,15 +174,15 @@ function parseTLV(data: Uint8Array): TLV {
return result return result
} }
export function nsecEncode(key: Uint8Array): `nsec1${string}` { export function nsecEncode(key: Uint8Array): NSec {
return encodeBytes('nsec', key) return encodeBytes('nsec', key)
} }
export function npubEncode(hex: string): `npub1${string}` { export function npubEncode(hex: string): NPub {
return encodeBytes('npub', hexToBytes(hex)) return encodeBytes('npub', hexToBytes(hex))
} }
export function noteEncode(hex: string): `note1${string}` { export function noteEncode(hex: string): Note {
return encodeBytes('note', hexToBytes(hex)) return encodeBytes('note', hexToBytes(hex))
} }
@@ -179,7 +195,7 @@ export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8A
return encodeBech32(prefix, bytes) return encodeBech32(prefix, bytes)
} }
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` { export function nprofileEncode(profile: ProfilePointer): NProfile {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(profile.pubkey)], 0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)), 1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
@@ -187,7 +203,7 @@ export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
return encodeBech32('nprofile', data) return encodeBech32('nprofile', data)
} }
export function neventEncode(event: EventPointer): `nevent1${string}` { export function neventEncode(event: EventPointer): NEvent {
let kindArray let kindArray
if (event.kind !== undefined) { if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind) kindArray = integerToUint8Array(event.kind)
@@ -203,7 +219,7 @@ export function neventEncode(event: EventPointer): `nevent1${string}` {
return encodeBech32('nevent', data) return encodeBech32('nevent', data)
} }
export function naddrEncode(addr: AddressPointer): `naddr1${string}` { export function naddrEncode(addr: AddressPointer): NAddr {
let kind = new ArrayBuffer(4) let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false) new DataView(kind).setUint32(0, addr.kind, false)
@@ -216,13 +232,6 @@ export function naddrEncode(addr: AddressPointer): `naddr1${string}` {
return encodeBech32('naddr', data) return encodeBech32('naddr', data)
} }
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)],
})
return encodeBech32('nrelay', data)
}
function encodeTLV(tlv: TLV): Uint8Array { function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = [] let entries: Uint8Array[] = []

View File

@@ -1,68 +1,77 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip27.ts' import { parse } from './nip27.ts'
test('matchAll', () => { test('first: parse simple content with 1 url and 1 nostr uri', () => {
const result = matchAll( const content = `nostr:npub1hpslpc8c5sp3e2nhm2fr7swsfqpys5vyjar5dwpn7e7decps6r8qkcln63 check out my profile:nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s; and this cool image https://images.com/image.jpg`
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', const blocks = Array.from(parse(content))
)
expect([...result]).toEqual([ expect(blocks).toEqual([
{ { type: 'reference', pointer: { pubkey: 'b861f0e0f8a4031caa77da923f41d04802485184974746b833f67cdce030d0ce' } },
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'text', text: ' check out my profile:' },
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6', { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
decoded: { { type: 'text', text: '; and this cool image ' },
type: 'npub', { type: 'image', url: 'https://images.com/image.jpg' },
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
start: 6,
end: 75,
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
},
start: 78,
end: 147,
},
]) ])
}) })
test('matchAll with an invalid nip19', () => { test('second: parse content with 3 urls of different types', () => {
const result = matchAll( const content = `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music:
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky', http://music.com/song.mp3
) and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`
const blocks = Array.from(parse(content))
expect([...result]).toEqual([ expect(blocks).toEqual([
{ type: 'text', text: ':' },
{ type: 'relay', url: 'wss://oa.ao/' },
{ type: 'text', text: "; this was a relay and now here's a video -> " },
{ type: 'video', url: 'https://videos.com/video.mp4' },
{ type: 'text', text: '! and some music:\n' },
{ type: 'audio', url: 'http://music.com/song.mp3' },
{ type: 'text', text: '\nand a regular link: ' },
{ type: 'url', url: 'https://regular.com/page?ok=true' },
{ {
decoded: { type: 'text',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b', text: '. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ',
type: 'note',
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
}, },
{ type: 'url', url: 'https://ok.com/' },
{ type: 'text', text: '!' },
]) ])
}) })
test('replaceAll', () => { test('third: parse complex content with 4 nostr uris and 3 urls', () => {
const content = const content = `Look at these profiles nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s nostr:nprofile1qqs8z4gwdjp6jwqlxhzk35dgpcgl50swljtal58q796f9ghdkexr02gppamhxue69uhhzamfv46jucm0d574e4uy check this event nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xl
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky' here's an image https://example.com/pic.png and another profile nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s
with a video https://example.com/vid.webm and finally https://example.com/docs`
const blocks = Array.from(parse(content))
const result = replaceAll(content, ({ decoded, value }) => { expect(blocks).toEqual([
switch (decoded.type) { { type: 'text', text: 'Look at these profiles ' },
case 'npub': { type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
return '@alex' { type: 'text', text: ' ' },
case 'note': {
return '!1234' type: 'reference',
default: pointer: {
return value pubkey: '71550e6c83a9381f35c568d1a80e11fa3e0efc97dfd0e0f17492a2edb64c37a9',
} relays: ['wss://qwieu.com'],
}) },
},
expect(result).toEqual('Hello @alex!\n\n!1234') { type: 'text', text: ' check this event ' },
{
type: 'reference',
pointer: {
id: '37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba',
relays: ['wss://zjbdksa.aswjdkn'],
author: undefined,
kind: undefined,
},
},
{ type: 'text', text: "\n here's an image " },
{ type: 'image', url: 'https://example.com/pic.png' },
{ type: 'text', text: ' and another profile ' },
{ type: 'reference', pointer: { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' } },
{ type: 'text', text: '\n with a video ' },
{ type: 'video', url: 'https://example.com/vid.webm' },
{ type: 'text', text: ' and finally ' },
{ type: 'url', url: 'https://example.com/docs' },
])
}) })

212
nip27.ts
View File

@@ -1,63 +1,169 @@
import { decode } from './nip19.ts' import { AddressPointer, EventPointer, ProfilePointer, decode } from './nip19.ts'
import { NOSTR_URI_REGEX, type NostrURI } from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */ export type Block =
export const regex = (): RegExp => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g') | {
type: 'text'
text: string
}
| {
type: 'reference'
pointer: ProfilePointer | AddressPointer | EventPointer
}
| {
type: 'url'
url: string
}
| {
type: 'relay'
url: string
}
| {
type: 'image'
url: string
}
| {
type: 'video'
url: string
}
| {
type: 'audio'
url: string
}
/** Match result for a Nostr URI in event content. */ const noCharacter = /\W/m
export interface NostrURIMatch extends NostrURI { const noURLCharacter = /\W |\W$|$|,| /m
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */ export function* parse(content: string): Iterable<Block> {
export function* matchAll(content: string): Iterable<NostrURIMatch> { const max = content.length
const matches = content.matchAll(regex()) let prevIndex = 0
let index = 0
while (index < max) {
let u = content.indexOf(':', index)
if (u === -1) {
// reached end
break
}
for (const match of matches) { if (content.substring(u - 5, u) === 'nostr') {
try { const m = content.substring(u + 60).match(noCharacter)
const [uri, value] = match const end = m ? u + 60 + m.index! : max
try {
let pointer: ProfilePointer | AddressPointer | EventPointer
let { data, type } = decode(content.substring(u + 1, end))
yield { switch (type) {
uri: uri as `nostr:${string}`, case 'npub':
value, pointer = { pubkey: data } as ProfilePointer
decoded: decode(value), break
start: match.index!, case 'nsec':
end: match.index! + uri.length, case 'note':
// ignore this, treat it as not a valid uri
index = end + 1
continue
default:
pointer = data as any
}
if (prevIndex !== u - 5) {
yield { type: 'text', text: content.substring(prevIndex, u - 5) }
}
yield { type: 'reference', pointer }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid nostr uri
index = u + 1
continue
} }
} catch (_e) { } else if (content.substring(u - 5, u) === 'https' || content.substring(u - 4, u) === 'http') {
// do nothing const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 5 : 4
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
if (
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
yield { type: 'image', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
yield { type: 'video', url: url.toString() }
index = end
prevIndex = index
continue
}
if (
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
yield { type: 'audio', url: url.toString() }
index = end
prevIndex = index
continue
}
yield { type: 'url', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else if (content.substring(u - 3, u) === 'wss' || content.substring(u - 2, u) === 'ws') {
const m = content.substring(u + 4).match(noURLCharacter)
const end = m ? u + 4 + m.index! : max
const prefixLen = content[u - 1] === 's' ? 3 : 2
try {
let url = new URL(content.substring(u - prefixLen, end))
if (url.hostname.indexOf('.') === -1) {
throw new Error('invalid ws url')
}
if (prevIndex !== u - prefixLen) {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
}
yield { type: 'relay', url: url.toString() }
index = end
prevIndex = index
continue
} catch (_err) {
// ignore this, not a valid url
index = end + 1
continue
}
} else {
// ignore this, it is nothing
index = u + 1
continue
} }
} }
}
/** if (prevIndex !== max) {
* Replace all occurrences of Nostr URIs in the text. yield { type: 'text', text: content.substring(prevIndex) }
* }
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(content: string, replacer: (match: NostrURI) => string): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
})
})
} }

View File

@@ -1,5 +1,11 @@
import { Event, finalizeEvent } from './pure.ts' import { Event, finalizeEvent } from './pure.ts'
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts' import {
ChannelCreation,
ChannelHideMessage,
ChannelMessage,
ChannelMetadata as KindChannelMetadata,
ChannelMuteUser,
} from './kinds.ts'
export interface ChannelMetadata { export interface ChannelMetadata {
name: string name: string
@@ -78,7 +84,7 @@ export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey
return finalizeEvent( return finalizeEvent(
{ {
kind: ChannelMetadata, kind: KindChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])], tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content, content: content,
created_at: t.created_at, created_at: t.created_at,

692
nip29.ts
View File

@@ -1,80 +1,522 @@
import { AbstractSimplePool } from './abstract-pool.ts' import { AbstractSimplePool } from './abstract-pool.ts'
import { Subscription } from './abstract-relay.ts' import { Subscription } from './abstract-relay.ts'
import { decode } from './nip19.ts' import type { Event, EventTemplate } from './core.ts'
import type { Event } from './core.ts' import { fetchRelayInformation, RelayInformation } from './nip11.ts'
import { fetchRelayInformation } from './nip11.ts' import { AddressPointer, decode } from './nip19.ts'
import { normalizeURL } from './utils.ts' import { normalizeURL } from './utils.ts'
import { AddressPointer } from './nip19.ts'
export function subscribeRelayGroups( /**
pool: AbstractSimplePool, * Represents a NIP29 group.
url: string, */
params: { export type Group = {
ongroups: (_: Group[]) => void relay: string
onerror: (_: Error) => void metadata: GroupMetadata
onconnect?: () => void admins?: GroupAdmin[]
}, members?: GroupMember[]
): () => void { reference: GroupReference
let normalized = normalizeURL(url)
let sub: Subscription
let groups: Group[] = []
fetchRelayInformation(normalized)
.then(async info => {
let rl = await pool.ensureRelay(normalized)
params.onconnect?.()
sub = rl.prepareSubscription(
[
{
kinds: [39000],
limit: 50,
authors: [info.pubkey],
},
],
{
onevent(event: Event) {
groups.push(parseGroup(event, normalized))
},
oneose() {
params.ongroups(groups)
sub.onevent = (event: Event) => {
groups.push(parseGroup(event, normalized))
params.ongroups(groups)
}
},
},
)
sub.fire()
})
.catch(params.onerror)
return () => sub.close()
} }
export async function loadGroup(pool: AbstractSimplePool, gr: GroupReference): Promise<Group> { /**
let normalized = normalizeURL(gr.host) * Represents the metadata for a NIP29 group.
*/
let info = await fetchRelayInformation(normalized) export type GroupMetadata = {
let event = await pool.get([normalized], { id: string
kinds: [39000], pubkey: string
authors: [info.pubkey], name?: string
'#d': [gr.id], picture?: string
}) about?: string
if (!event) throw new Error(`group '${gr.id}' not found on ${gr.host}`) isPublic?: boolean
return parseGroup(event, normalized) isOpen?: boolean
}
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
let gr = parseGroupCode(code)
if (!gr) throw new Error(`code "${code}" does not identify a group`)
return loadGroup(pool, gr)
} }
/**
* Represents a NIP29 group reference.
*/
export type GroupReference = { export type GroupReference = {
id: string id: string
host: string host: string
} }
/**
* Represents a NIP29 group member.
*/
export type GroupMember = {
pubkey: string
label?: string
}
/**
* Represents a NIP29 group admin.
*/
export type GroupAdmin = {
pubkey: string
label?: string
permissions: GroupAdminPermission[]
}
/**
* Represents the permissions that a NIP29 group admin can have.
*/
export enum GroupAdminPermission {
/** @deprecated use PutUser instead */
AddUser = 'add-user',
EditMetadata = 'edit-metadata',
DeleteEvent = 'delete-event',
RemoveUser = 'remove-user',
/** @deprecated removed from NIP */
AddPermission = 'add-permission',
/** @deprecated removed from NIP */
RemovePermission = 'remove-permission',
/** @deprecated removed from NIP */
EditGroupStatus = 'edit-group-status',
PutUser = 'put-user',
CreateGroup = 'create-group',
DeleteGroup = 'delete-group',
CreateInvite = 'create-invite',
}
/**
* Generates a group metadata event template.
*
* @param group - The group object.
* @returns An event template with the generated group metadata that can be signed later.
*/
export function generateGroupMetadataEventTemplate(group: Group): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
group.metadata.name && tags.push(['name', group.metadata.name])
group.metadata.picture && tags.push(['picture', group.metadata.picture])
group.metadata.about && tags.push(['about', group.metadata.about])
group.metadata.isPublic && tags.push(['public'])
group.metadata.isOpen && tags.push(['open'])
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39000,
tags,
}
}
/**
* Validates a group metadata event.
*
* @param event - The event to validate.
* @returns A boolean indicating whether the event is valid.
*/
export function validateGroupMetadataEvent(event: Event): boolean {
if (event.kind !== 39000) return false
if (!event.pubkey) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Generates an event template for group admins.
*
* @param group - The group object.
* @param admins - An array of group admins.
* @returns The generated event template with the group admins that can be signed later.
*/
export function generateGroupAdminsEventTemplate(group: Group, admins: GroupAdmin[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const admin of admins) {
tags.push(['p', admin.pubkey, admin.label || '', ...admin.permissions])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39001,
tags,
}
}
/**
* Validates a group admins event.
*
* @param event - The event to validate.
* @returns True if the event is valid, false otherwise.
*/
export function validateGroupAdminsEvent(event: Event): boolean {
if (event.kind !== 39001) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
// validate permissions
for (const [tag, _value, _label, ...permissions] of event.tags) {
if (tag !== 'p') continue
for (let i = 0; i < permissions.length; i += 1) {
if (typeof permissions[i] !== 'string') return false
// validate permission name from the GroupAdminPermission enum
if (!Object.values(GroupAdminPermission).includes(permissions[i] as GroupAdminPermission)) return false
}
}
return true
}
/**
* Generates an event template for a group with its members.
*
* @param group - The group object.
* @param members - An array of group members.
* @returns The generated event template with the group members that can be signed later.
*/
export function generateGroupMembersEventTemplate(group: Group, members: GroupMember[]): EventTemplate {
const tags: string[][] = [['d', group.metadata.id]]
for (const member of members) {
tags.push(['p', member.pubkey, member.label || ''])
}
return {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 39002,
tags,
}
}
/**
* Validates a group members event.
*
* @param event - The event to validate.
* @returns Returns `true` if the event is a valid group members event, `false` otherwise.
*/
export function validateGroupMembersEvent(event: Event): boolean {
if (event.kind !== 39002) return false
const requiredTags = ['d'] as const
for (const tag of requiredTags) {
if (!event.tags.find(([t]) => t == tag)) return false
}
return true
}
/**
* Returns the normalized relay URL based on the provided group reference.
*
* @param groupReference - The group reference object containing the host.
* @returns The normalized relay URL.
*/
export function getNormalizedRelayURLByGroupReference(groupReference: GroupReference): string {
return normalizeURL(groupReference.host)
}
/**
* Fetches relay information by group reference.
*
* @param groupReference The group reference.
* @returns A promise that resolves to the relay information.
*/
export async function fetchRelayInformationByGroupReference(groupReference: GroupReference): Promise<RelayInformation> {
const normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
return fetchRelayInformation(normalizedRelayURL)
}
/**
* Fetches the group metadata event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group metadata event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group metadata event that can be parsed later to get the group metadata object.
* @throws {Error} If the group is not found on the specified relay.
*/
export async function fetchGroupMetadataEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMetadataEvent = await pool.get([normalizedRelayURL], {
kinds: [39000],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMetadataEvent) throw new Error(`group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMetadataEvent
}
/**
* Parses a group metadata event and returns the corresponding GroupMetadata object.
*
* @param event - The event to parse.
* @returns The parsed GroupMetadata object.
* @throws An error if the group metadata event is invalid.
*/
export function parseGroupMetadataEvent(event: Event): GroupMetadata {
if (!validateGroupMetadataEvent(event)) throw new Error('invalid group metadata event')
const metadata: GroupMetadata = {
id: '',
pubkey: event.pubkey,
}
for (const [tag, value] of event.tags) {
switch (tag) {
case 'd':
metadata.id = value
break
case 'name':
metadata.name = value
break
case 'picture':
metadata.picture = value
break
case 'about':
metadata.about = value
break
case 'public':
metadata.isPublic = true
break
case 'open':
metadata.isOpen = true
break
}
}
return metadata
}
/**
* Fetches the group admins event from the specified pool.
* If the normalizedRelayURL is not provided, it will be obtained from the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool to fetch the group admins event from.
* @param {GroupReference} options.groupReference - The reference to the group.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information.
* @returns {Promise<Event>} The group admins event that can be parsed later to get the group admins object.
* @throws {Error} If the group admins event is not found on the specified relay.
*/
export async function fetchGroupAdminsEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupAdminsEvent = await pool.get([normalizedRelayURL], {
kinds: [39001],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupAdminsEvent) throw new Error(`admins for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupAdminsEvent
}
/**
* Parses a group admins event and returns an array of GroupAdmin objects.
*
* @param event - The event to parse.
* @returns An array of GroupAdmin objects.
* @throws Throws an error if the group admins event is invalid.
*/
export function parseGroupAdminsEvent(event: Event): GroupAdmin[] {
if (!validateGroupAdminsEvent(event)) throw new Error('invalid group admins event')
const admins: GroupAdmin[] = []
for (const [tag, value, label, ...permissions] of event.tags) {
if (tag !== 'p') continue
admins.push({
pubkey: value,
label,
permissions: permissions as GroupAdminPermission[],
})
}
return admins
}
/**
* Fetches the group members event from the specified relay.
* If the normalizedRelayURL is not provided, it will be obtained using the groupReference.
* If the relayInformation is not provided, it will be fetched using the normalizedRelayURL.
*
* @param {Object} options - The options object.
* @param {AbstractSimplePool} options.pool - The pool object.
* @param {GroupReference} options.groupReference - The group reference object.
* @param {string} [options.normalizedRelayURL] - The normalized relay URL.
* @param {RelayInformation} [options.relayInformation] - The relay information object.
* @returns {Promise<Event>} The group members event that can be parsed later to get the group members object.
* @throws {Error} If the group members event is not found.
*/
export async function fetchGroupMembersEvent({
pool,
groupReference,
relayInformation,
normalizedRelayURL,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Event> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const groupMembersEvent = await pool.get([normalizedRelayURL], {
kinds: [39002],
authors: [relayInformation.pubkey],
'#d': [groupReference.id],
})
if (!groupMembersEvent) throw new Error(`members for group '${groupReference.id}' not found on ${normalizedRelayURL}`)
return groupMembersEvent
}
/**
* Parses a group members event and returns an array of GroupMember objects.
* @param event - The event to parse.
* @returns An array of GroupMember objects.
* @throws Throws an error if the group members event is invalid.
*/
export function parseGroupMembersEvent(event: Event): GroupMember[] {
if (!validateGroupMembersEvent(event)) throw new Error('invalid group members event')
const members: GroupMember[] = []
for (const [tag, value, label] of event.tags) {
if (tag !== 'p') continue
members.push({
pubkey: value,
label,
})
}
return members
}
/**
* Fetches and parses the group metadata event, group admins event, and group members event from the specified pool.
* If the normalized relay URL is not provided, it will be obtained using the group reference.
* If the relay information is not provided, it will be fetched using the normalized relay URL.
*
* @param {Object} options - The options for loading the group.
* @param {AbstractSimplePool} options.pool - The pool to load the group from.
* @param {GroupReference} options.groupReference - The reference of the group to load.
* @param {string} [options.normalizedRelayURL] - The normalized URL of the relay to use.
* @param {RelayInformation} [options.relayInformation] - The relay information to use.
* @returns {Promise<Group>} A promise that resolves to the loaded group.
*/
export async function loadGroup({
pool,
groupReference,
normalizedRelayURL,
relayInformation,
}: {
pool: AbstractSimplePool
groupReference: GroupReference
normalizedRelayURL?: string
relayInformation?: RelayInformation
}): Promise<Group> {
if (!normalizedRelayURL) {
normalizedRelayURL = getNormalizedRelayURLByGroupReference(groupReference)
}
if (!relayInformation) {
relayInformation = await fetchRelayInformation(normalizedRelayURL)
}
const metadataEvent = await fetchGroupMetadataEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const metadata = parseGroupMetadataEvent(metadataEvent)
const adminsEvent = await fetchGroupAdminsEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const admins = parseGroupAdminsEvent(adminsEvent)
const membersEvent = await fetchGroupMembersEvent({ pool, groupReference, normalizedRelayURL, relayInformation })
const members = parseGroupMembersEvent(membersEvent)
const group: Group = {
relay: normalizedRelayURL,
metadata,
admins,
members,
reference: groupReference,
}
return group
}
/**
* Loads a group from the specified pool using the provided group code.
*
* @param {AbstractSimplePool} pool - The pool to load the group from.
* @param {string} code - The code representing the group.
* @returns {Promise<Group>} - A promise that resolves to the loaded group.
* @throws {Error} - If the group code is invalid.
*/
export async function loadGroupFromCode(pool: AbstractSimplePool, code: string): Promise<Group> {
const groupReference = parseGroupCode(code)
if (!groupReference) throw new Error('invalid group code')
return loadGroup({ pool, groupReference })
}
/**
* Parses a group code and returns a GroupReference object.
*
* @param code The group code to parse.
* @returns A GroupReference object if the code is valid, otherwise null.
*/
export function parseGroupCode(code: string): null | GroupReference { export function parseGroupCode(code: string): null | GroupReference {
if (code.startsWith('naddr1')) { if (code.startsWith('naddr1')) {
try { try {
@@ -99,68 +541,74 @@ export function parseGroupCode(code: string): null | GroupReference {
return null return null
} }
/**
* Encodes a group reference into a string.
*
* @param gr - The group reference to encode.
* @returns The encoded group reference as a string.
*/
export function encodeGroupReference(gr: GroupReference): string { export function encodeGroupReference(gr: GroupReference): string {
if (gr.host.startsWith('https://')) gr.host = gr.host.slice(8) const { host, id } = gr
if (gr.host.startsWith('wss://')) gr.host = gr.host.slice(6) const normalizedHost = host.replace(/^(https?:\/\/|wss?:\/\/)/, '')
return `${gr.host}'${gr.id}`
return `${normalizedHost}'${id}`
} }
export type Group = { /**
id: string * Subscribes to relay groups metadata events and calls the provided event handler function
relay: string * when an event is received.
pubkey: string *
name?: string * @param {Object} options - The options for subscribing to relay groups metadata events.
picture?: string * @param {AbstractSimplePool} options.pool - The pool to subscribe to.
about?: string * @param {string} options.relayURL - The URL of the relay.
public?: boolean * @param {Function} options.onError - The error handler function.
open?: boolean * @param {Function} options.onEvent - The event handler function.
} * @param {Function} [options.onConnect] - The connect handler function.
* @returns {Function} - A function to close the subscription
*/
export function subscribeRelayGroupsMetadataEvents({
pool,
relayURL,
onError,
onEvent,
onConnect,
}: {
pool: AbstractSimplePool
relayURL: string
onError: (err: Error) => void
onEvent: (event: Event) => void
onConnect?: () => void
}): () => void {
let sub: Subscription
export function parseGroup(event: Event, relay: string): Group { const normalizedRelayURL = normalizeURL(relayURL)
const group: Partial<Group> = { relay, pubkey: event.pubkey }
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i]
switch (tag[0]) {
case 'd':
group.id = tag[1] || ''
break
case 'name':
group.name = tag[1] || ''
break
case 'about':
group.about = tag[1] || ''
break
case 'picture':
group.picture = tag[1] || ''
break
case 'open':
group.open = true
break
case 'public':
group.public = true
break
}
}
return group as Group
}
export type Member = { fetchRelayInformation(normalizedRelayURL)
pubkey: string .then(async info => {
label?: string const abstractedRelay = await pool.ensureRelay(normalizedRelayURL)
permissions: string[]
}
export function parseMembers(event: Event): Member[] { onConnect?.()
const members = []
for (let i = 0; i < event.tags.length; i++) { sub = abstractedRelay.prepareSubscription(
const tag = event.tags[i] [
if (tag.length < 2) continue {
if (tag[0] !== 'p') continue kinds: [39000],
if (!tag[1].match(/^[0-9a-f]{64}$/)) continue limit: 50,
const member: Member = { pubkey: tag[1], permissions: [] } authors: [info.pubkey],
if (tag.length > 2) member.label = tag[2] },
if (tag.length > 3) member.permissions = tag.slice(3) ],
members.push(member) {
} onevent(event: Event) {
return members onEvent(event)
},
},
)
})
.catch(err => {
sub.close()
onError(err)
})
return () => sub.close()
} }

View File

@@ -1,7 +1,7 @@
import { test, expect } from 'bun:test' import { test, expect } from 'bun:test'
import { v2 } from './nip44.js' import { v2 } from './nip44.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { default as vec } from './nip44.vectors.json' assert { type: 'json' } import { default as vec } from './nip44.vectors.json' with { type: 'json' }
import { schnorr } from '@noble/curves/secp256k1' import { schnorr } from '@noble/curves/secp256k1'
const v2vec = vec.v2 const v2vec = vec.v2

View File

@@ -1,9 +1,7 @@
import { hexToBytes } from '@noble/hashes/utils' import { EventTemplate, NostrEvent, VerifiedEvent } from './core.ts'
import { NostrEvent, UnsignedEvent, VerifiedEvent } from './core.ts'
import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts' import { generateSecretKey, finalizeEvent, getPublicKey, verifyEvent } from './pure.ts'
import { AbstractSimplePool, SubCloser } from './abstract-pool.ts' import { AbstractSimplePool, SubCloser } from './abstract-pool.ts'
import { decrypt, encrypt } from './nip04.ts' import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { getConversationKey, decrypt as nip44decrypt } from './nip44.ts'
import { NIP05_REGEX } from './nip05.ts' import { NIP05_REGEX } from './nip05.ts'
import { SimplePool } from './pool.ts' import { SimplePool } from './pool.ts'
import { Handlerinformation, NostrConnect } from './kinds.ts' import { Handlerinformation, NostrConnect } from './kinds.ts'
@@ -49,7 +47,7 @@ export async function parseBunkerInput(input: string): Promise<BunkerPointer | n
return queryBunkerProfile(input) return queryBunkerProfile(input)
} }
async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> { export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer | null> {
const match = nip05.match(NIP05_REGEX) const match = nip05.match(NIP05_REGEX)
if (!match) return null if (!match) return null
@@ -75,7 +73,7 @@ export type BunkerSignerParams = {
export class BunkerSigner { export class BunkerSigner {
private pool: AbstractSimplePool private pool: AbstractSimplePool
private subCloser: SubCloser private subCloser: SubCloser | undefined
private isOpen: boolean private isOpen: boolean
private serial: number private serial: number
private idPrefix: string private idPrefix: string
@@ -87,8 +85,11 @@ export class BunkerSigner {
} }
private waitingForAuth: { [id: string]: boolean } private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array private secretKey: Uint8Array
private conversationKey: Uint8Array
public bp: BunkerPointer public bp: BunkerPointer
private cachedPubKey: string | undefined
/** /**
* Creates a new instance of the Nip46 class. * Creates a new instance of the Nip46 class.
* @param relays - An array of relay addresses. * @param relays - An array of relay addresses.
@@ -102,6 +103,7 @@ export class BunkerSigner {
this.pool = params.pool || new SimplePool() this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp this.bp = bp
this.isOpen = false this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7) this.idPrefix = Math.random().toString(36).substring(7)
@@ -109,22 +111,20 @@ export class BunkerSigner {
this.listeners = {} this.listeners = {}
this.waitingForAuth = {} this.waitingForAuth = {}
this.setupSubscription(params)
}
private setupSubscription(params: BunkerSignerParams) {
const listeners = this.listeners const listeners = this.listeners
const waitingForAuth = this.waitingForAuth const waitingForAuth = this.waitingForAuth
const skBytes = this.secretKey const convKey = this.conversationKey
this.subCloser = this.pool.subscribeMany( this.subCloser = this.pool.subscribe(
this.bp.relays, this.bp.relays,
[{ kinds: [NostrConnect], '#p': [getPublicKey(this.secretKey)] }], { kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] },
{ {
async onevent(event: NostrEvent) { onevent: async (event: NostrEvent) => {
let o const o = JSON.parse(decrypt(event.content, convKey))
try {
o = JSON.parse(await decrypt(clientSecretKey, event.pubkey, event.content))
} catch (err) {
o = JSON.parse(nip44decrypt(event.content, getConversationKey(skBytes, event.pubkey)))
}
const { id, result, error } = o const { id, result, error } = o
if (result === 'auth_url' && waitingForAuth[id]) { if (result === 'auth_url' && waitingForAuth[id]) {
@@ -134,7 +134,7 @@ export class BunkerSigner {
params.onauth(error) params.onauth(error)
} else { } else {
console.warn( console.warn(
`nostr-tools/nip46: remote signer ${bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`, `nostr-tools/nip46: remote signer ${this.bp.pubkey} tried to send an "auth_url"='${error}' but there was no onauth() callback configured.`,
) )
} }
return return
@@ -147,6 +147,13 @@ export class BunkerSigner {
delete listeners[id] delete listeners[id]
} }
}, },
onclose: () => {
if (this.isOpen) {
// If we get onclose but isOpen is still true, that means the client still wants to stay connected
this.subCloser!.close()
this.setupSubscription(params)
}
},
}, },
) )
this.isOpen = true this.isOpen = true
@@ -155,7 +162,7 @@ export class BunkerSigner {
// closes the subscription -- this object can't be used anymore after this // closes the subscription -- this object can't be used anymore after this
async close() { async close() {
this.isOpen = false this.isOpen = false
this.subCloser.close() this.subCloser!.close()
} }
async sendRequest(method: string, params: string[]): Promise<string> { async sendRequest(method: string, params: string[]): Promise<string> {
@@ -165,7 +172,7 @@ export class BunkerSigner {
this.serial++ this.serial++
const id = `${this.idPrefix}-${this.serial}` const id = `${this.idPrefix}-${this.serial}`
const encryptedContent = await encrypt(this.secretKey, this.bp.pubkey, JSON.stringify({ id, method, params })) const encryptedContent = encrypt(JSON.stringify({ id, method, params }), this.conversationKey)
// the request event // the request event
const verifiedEvent: VerifiedEvent = finalizeEvent( const verifiedEvent: VerifiedEvent = finalizeEvent(
@@ -207,15 +214,20 @@ export class BunkerSigner {
} }
/** /**
* This was supposed to call the "get_public_key" method on the bunker, * Calls the "get_public_key" method on the bunker.
* but instead we just returns the public key we already know. * (before we would return the public key hardcoded in the bunker parameters, but
* that is not correct as that may be the bunker pubkey and the actual signer
* pubkey may be different.)
*/ */
async getPublicKey(): Promise<string> { async getPublicKey(): Promise<string> {
return this.bp.pubkey if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', [])
}
return this.cachedPubKey
} }
/** /**
* Calls the "get_relays" method on the bunker. * @deprecated removed from NIP
*/ */
async getRelays(): Promise<RelayRecord> { async getRelays(): Promise<RelayRecord> {
return JSON.parse(await this.sendRequest('get_relays', [])) return JSON.parse(await this.sendRequest('get_relays', []))
@@ -226,10 +238,10 @@ export class BunkerSigner {
* @param event - The event to sign. * @param event - The event to sign.
* @returns A Promise that resolves to the signed event. * @returns A Promise that resolves to the signed event.
*/ */
async signEvent(event: UnsignedEvent): Promise<VerifiedEvent> { async signEvent(event: EventTemplate): Promise<VerifiedEvent> {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]) let resp = await this.sendRequest('sign_event', [JSON.stringify(event)])
let signed: NostrEvent = JSON.parse(resp) let signed: NostrEvent = JSON.parse(resp)
if (signed.pubkey === this.bp.pubkey && verifyEvent(signed)) { if (verifyEvent(signed)) {
return signed return signed
} else { } else {
throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`) throw new Error(`event returned from bunker is improperly signed: ${JSON.stringify(signed)}`)
@@ -244,17 +256,12 @@ export class BunkerSigner {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]) return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext])
} }
async nip44GetKey(thirdPartyPubkey: string): Promise<Uint8Array> {
let resp = await this.sendRequest('nip44_get_key', [thirdPartyPubkey])
return hexToBytes(resp)
}
async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> { async nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]) return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext])
} }
async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> { async nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise<string> {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, ciphertext]) return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext])
} }
} }
@@ -291,9 +298,6 @@ export async function createAccount(
return rpc return rpc
} }
// @deprecated use fetchBunkerProviders instead
export const fetchCustodialBunkers = fetchBunkerProviders
/** /**
* Fetches info on available providers that announce themselves using NIP-89 events. * Fetches info on available providers that announce themselves using NIP-89 events.
* @returns A promise that resolves to an array of available bunker objects. * @returns A promise that resolves to an array of available bunker objects.

View File

@@ -1,10 +1,15 @@
import { scrypt } from '@noble/hashes/scrypt' import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha' import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils' import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { Bech32MaxSize, encodeBytes } from './nip19.ts' import { Bech32MaxSize, Ncryptsec, encodeBytes } from './nip19.ts'
import { bech32 } from '@scure/base' import { bech32 } from '@scure/base'
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string { export function encrypt(
sec: Uint8Array,
password: string,
logn: number = 16,
ksb: 0x00 | 0x01 | 0x02 = 0x02,
): Ncryptsec {
let salt = randomBytes(16) let salt = randomBytes(16)
let n = 2 ** logn let n = 2 ** logn
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 }) let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })

42
nip54.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import { describe, test, expect } from 'bun:test'
import { normalizeIdentifier } from './nip54.ts'
describe('normalizeIdentifier', () => {
test('converts to lowercase', () => {
expect(normalizeIdentifier('HELLO')).toBe('hello')
expect(normalizeIdentifier('MixedCase')).toBe('mixedcase')
})
test('trims whitespace', () => {
expect(normalizeIdentifier(' hello ')).toBe('hello')
expect(normalizeIdentifier('\thello\n')).toBe('hello')
})
test('normalizes Unicode to NFKC form', () => {
// é can be represented as single char é (U+00E9) or e + ´ (U+0065 U+0301)
expect(normalizeIdentifier('café')).toBe('café')
expect(normalizeIdentifier('cafe\u0301')).toBe('café')
})
test('replaces non-alphanumeric characters with hyphens', () => {
expect(normalizeIdentifier('hello world')).toBe('hello-world')
expect(normalizeIdentifier('user@example.com')).toBe('user-example-com')
expect(normalizeIdentifier('$special#chars!')).toBe('-special-chars-')
})
test('preserves numbers', () => {
expect(normalizeIdentifier('user123')).toBe('user123')
expect(normalizeIdentifier('2fast4you')).toBe('2fast4you')
})
test('handles multiple consecutive special characters', () => {
expect(normalizeIdentifier('hello!!!world')).toBe('hello---world')
expect(normalizeIdentifier('multiple spaces')).toBe('multiple---spaces')
})
test('handles Unicode letters from different scripts', () => {
expect(normalizeIdentifier('привет')).toBe('привет')
expect(normalizeIdentifier('こんにちは')).toBe('こんにちは')
expect(normalizeIdentifier('مرحبا')).toBe('مرحبا')
})
})

19
nip54.ts Normal file
View File

@@ -0,0 +1,19 @@
export function normalizeIdentifier(name: string): string {
// Trim and lowercase
name = name.trim().toLowerCase()
// Normalize Unicode to NFKC form
name = name.normalize('NFKC')
// Convert to array of characters and map each one
return Array.from(name)
.map(char => {
// Check if character is letter or number using Unicode ranges
if (/\p{Letter}/u.test(char) || /\p{Number}/u.test(char)) {
return char
}
return '-'
})
.join('')
}

166
nip55.test.ts Normal file
View File

@@ -0,0 +1,166 @@
import { test, expect } from 'bun:test'
import * as nip55 from './nip55.js'
// Function to parse the NostrSigner URI
function parseNostrSignerUri(uri: string) {
const [base, query] = uri.split('?')
const basePart = base.replace('nostrsigner:', '')
let jsonObject = null
if (basePart) {
try {
jsonObject = JSON.parse(decodeURIComponent(basePart))
} catch (e) {
console.warn('Failed to parse base JSON:', e)
}
}
const urlSearchParams = new URLSearchParams(query)
const queryParams = Object.fromEntries(urlSearchParams.entries())
if (queryParams.permissions) {
queryParams.permissions = JSON.parse(decodeURIComponent(queryParams.permissions))
}
return {
base: jsonObject,
...queryParams,
}
}
// Test cases
test('Get Public Key URI', () => {
const permissions = [{ type: 'sign_event', kind: 22242 }, { type: 'nip44_decrypt' }]
const callbackUrl = 'https://example.com/?event='
const uri = nip55.getPublicKeyUri({
permissions,
callbackUrl,
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'get_public_key')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', 'https://example.com/?event=')
expect(jsonObject).toHaveProperty('permissions[0].type', 'sign_event')
expect(jsonObject).toHaveProperty('permissions[0].kind', 22242)
expect(jsonObject).toHaveProperty('permissions[1].type', 'nip44_decrypt')
})
test('Sign Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.signEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('base.content', 'test')
expect(jsonObject).toHaveProperty('type', 'sign_event')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})
test('Encrypt NIP-04 URI', () => {
const callbackUrl = 'https://example.com/?event='
const uri = nip55.encryptNip04Uri({
callbackUrl,
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('callbackUrl', callbackUrl)
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-04 URI', () => {
const uri = nip55.decryptNip04Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip04_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Encrypt NIP-44 URI', () => {
const uri = nip55.encryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'plainText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_encrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('plainText', 'plainText')
})
test('Decrypt NIP-44 URI', () => {
const uri = nip55.decryptNip44Uri({
id: 'some_id',
currentUser: 'hex_pub_key',
pubKey: 'hex_pub_key',
content: 'encryptedText',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'nip44_decrypt')
expect(jsonObject).toHaveProperty('compressionType', 'none')
expect(jsonObject).toHaveProperty('returnType', 'signature')
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
expect(jsonObject).toHaveProperty('pubKey', 'hex_pub_key')
expect(jsonObject).toHaveProperty('encryptedText', 'encryptedText')
})
test('Decrypt Zap Event URI', () => {
const eventJson = { kind: 1, content: 'test' }
const uri = nip55.decryptZapEventUri({
eventJson,
id: 'some_id',
currentUser: 'hex_pub_key',
returnType: 'event',
compressionType: 'gzip',
})
const jsonObject = parseNostrSignerUri(uri)
expect(jsonObject).toHaveProperty('type', 'decrypt_zap_event')
expect(jsonObject).toHaveProperty('compressionType', 'gzip')
expect(jsonObject).toHaveProperty('returnType', 'event')
expect(jsonObject).toHaveProperty('base.kind', 1)
expect(jsonObject).toHaveProperty('id', 'some_id')
expect(jsonObject).toHaveProperty('current_user', 'hex_pub_key')
})

123
nip55.ts Normal file
View File

@@ -0,0 +1,123 @@
type BaseParams = {
callbackUrl?: string
returnType?: 'signature' | 'event'
compressionType?: 'none' | 'gzip'
}
type PermissionsParams = BaseParams & {
permissions?: { type: string; kind?: number }[]
}
type EventUriParams = BaseParams & {
eventJson: Record<string, unknown>
id?: string
currentUser?: string
}
type EncryptDecryptParams = BaseParams & {
pubKey: string
content: string
id?: string
currentUser?: string
}
type UriParams = BaseParams & {
base: string
type: string
id?: string
currentUser?: string
permissions?: { type: string; kind?: number }[]
pubKey?: string
plainText?: string
encryptedText?: string
appName?: string
}
function encodeParams(params: Record<string, unknown>): string {
return new URLSearchParams(params as Record<string, string>).toString()
}
function filterUndefined<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T
}
function buildUri({
base,
type,
callbackUrl,
returnType = 'signature',
compressionType = 'none',
...params
}: UriParams): string {
const baseParams = {
type,
compressionType,
returnType,
callbackUrl,
id: params.id,
current_user: params.currentUser,
permissions:
params.permissions && params.permissions.length > 0
? encodeURIComponent(JSON.stringify(params.permissions))
: undefined,
pubKey: params.pubKey,
plainText: params.plainText,
encryptedText: params.encryptedText,
appName: params.appName,
}
const filteredParams = filterUndefined(baseParams)
return `${base}?${encodeParams(filteredParams)}`
}
function buildDefaultUri(type: string, params: Partial<UriParams>): string {
return buildUri({
base: 'nostrsigner:',
type,
...params,
})
}
export function getPublicKeyUri({ permissions = [], ...params }: PermissionsParams): string {
return buildDefaultUri('get_public_key', { permissions, ...params })
}
export function signEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'sign_event',
...params,
})
}
function encryptUri(type: 'nip44_encrypt' | 'nip04_encrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, plainText: params.content })
}
function decryptUri(type: 'nip44_decrypt' | 'nip04_decrypt', params: EncryptDecryptParams): string {
return buildDefaultUri(type, { ...params, encryptedText: params.content })
}
export function encryptNip04Uri(params: EncryptDecryptParams): string {
return encryptUri('nip04_encrypt', params)
}
export function decryptNip04Uri(params: EncryptDecryptParams): string {
return decryptUri('nip04_decrypt', params)
}
export function encryptNip44Uri(params: EncryptDecryptParams): string {
return encryptUri('nip44_encrypt', params)
}
export function decryptNip44Uri(params: EncryptDecryptParams): string {
return decryptUri('nip44_decrypt', params)
}
export function decryptZapEventUri({ eventJson, ...params }: EventUriParams): string {
return buildUri({
base: `nostrsigner:${encodeURIComponent(JSON.stringify(eventJson))}`,
type: 'decrypt_zap_event',
...params,
})
}

View File

@@ -2,6 +2,7 @@ import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts' import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts' import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts'
var _fetch: any var _fetch: any
@@ -49,7 +50,7 @@ export function makeZapRequest({
comment = '', comment = '',
}: { }: {
profile: string profile: string
event: string | null event: string | Event | null
amount: number amount: number
comment: string comment: string
relays: string[] relays: string[]
@@ -68,9 +69,22 @@ export function makeZapRequest({
], ],
} }
if (event) { if (event && typeof event === 'string') {
zr.tags.push(['e', event]) zr.tags.push(['e', event])
} }
if (event && typeof event === 'object') {
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
zr.tags.push(a)
// addressable event
} else if (isAddressableKind(event.kind)) {
let d = event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`]
zr.tags.push(a)
}
}
return zr return zr
} }

113
nip59.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { test, expect } from 'bun:test'
import { wrapEvent, wrapManyEvents, unwrapEvent, unwrapManyEvents } from './nip59.ts'
import { decode } from './nip19.ts'
import { NostrEvent, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
import { GiftWrap } from './kinds.ts'
import { hexToBytes } from '@noble/hashes/utils'
const senderPrivateKey = decode(`nsec1p0ht6p3wepe47sjrgesyn4m50m6avk2waqudu9rl324cg2c4ufesyp6rdg`).data
const recipientPrivateKey = decode(`nsec1uyyrnx7cgfp40fcskcr2urqnzekc20fj0er6de0q8qvhx34ahazsvs9p36`).data
const recipientPublicKey = getPublicKey(recipientPrivateKey)
const event = {
kind: 1,
content: 'Are you going to the party tonight?',
}
const wrappedEvent = wrapEvent(event, senderPrivateKey, recipientPublicKey)
test('wrapEvent', () => {
const expected = {
content: '',
id: '',
created_at: 1728537932,
kind: 1059,
pubkey: '',
sig: '',
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
[Symbol('verified')]: true,
}
const result = wrapEvent(event, senderPrivateKey, recipientPublicKey)
expect(result.kind).toEqual(expected.kind)
expect(result.tags).toEqual(expected.tags)
})
test('wrapManyEvent', () => {
const expected = [
{
kind: 1059,
content: '',
created_at: 1729581521,
tags: [['p', '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
{
kind: 1059,
content: '',
created_at: 1729594619,
tags: [['p', '166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99']],
pubkey: '',
id: '',
sig: '',
[Symbol('verified')]: true,
},
]
const wrappedEvents = wrapManyEvents(event, senderPrivateKey, [recipientPublicKey])
wrappedEvents.forEach((event, index) => {
expect(event.kind).toEqual(expected[index].kind)
expect(event.tags).toEqual(expected[index].tags)
})
})
test('unwrapEvent', () => {
const expected = {
kind: 1,
content: 'Are you going to the party tonight?',
pubkey: '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9',
tags: [],
}
const result = unwrapEvent(wrappedEvent, recipientPrivateKey)
expect(result.kind).toEqual(expected.kind)
expect(result.content).toEqual(expected.content)
expect(result.pubkey).toEqual(expected.pubkey)
expect(result.tags).toEqual(expected.tags)
})
test('getWrappedEvents and unwrapManyEvents', async () => {
const expected = [
{
created_at: 1729721879,
content: 'Hello!',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: 'aee0a3e6487b2ac8c1851cc84f3ae0fca9af8a9bdad85c4ba5fdf45d3ee817c3',
},
{
created_at: 1729722025,
content: 'How are you?',
tags: [['p', '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa']],
kind: 14,
pubkey: 'c0f56665e73eedc90b9565ecb34d961a2eb7ac1e2747899e4f73a813f940bc22',
id: '212387ec5efee7d6eb20b747121e9fc1adb798de6c3185e932335bb1bcc61a77',
},
]
const relays = ['wss://relay.damus.io', 'wss://nos.lol']
const privateKey = hexToBytes('582c3e7902c10c84d1cfe899a102e56bde628972d58d63011163ce0cdf4279b6')
const publicKey = '33d6bb037bf2e8c4571708e480e42d141bedc5a562b4884ec233b22d6fdea6aa'
const pool = new SimplePool()
const wrappedEvents: NostrEvent[] = await pool.querySync(relays, { kinds: [GiftWrap], '#p': [publicKey] })
const unwrappedEvents = unwrapManyEvents(wrappedEvents, privateKey)
unwrappedEvents.forEach((event, index) => {
expect(event).toEqual(expected[index])
})
})

107
nip59.ts Normal file
View File

@@ -0,0 +1,107 @@
import { EventTemplate, UnsignedEvent, NostrEvent } from './core.ts'
import { getConversationKey, decrypt, encrypt } from './nip44.ts'
import { getEventHash, generateSecretKey, finalizeEvent, getPublicKey } from './pure.ts'
import { Seal, GiftWrap } from './kinds.ts'
type Rumor = UnsignedEvent & { id: string }
const TWO_DAYS = 2 * 24 * 60 * 60
const now = () => Math.round(Date.now() / 1000)
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
const nip44ConversationKey = (privateKey: Uint8Array, publicKey: string) => getConversationKey(privateKey, publicKey)
const nip44Encrypt = (data: EventTemplate, privateKey: Uint8Array, publicKey: string) =>
encrypt(JSON.stringify(data), nip44ConversationKey(privateKey, publicKey))
const nip44Decrypt = (data: NostrEvent, privateKey: Uint8Array) =>
JSON.parse(decrypt(data.content, nip44ConversationKey(privateKey, data.pubkey)))
export function createRumor(event: Partial<UnsignedEvent>, privateKey: Uint8Array): Rumor {
const rumor = {
created_at: now(),
content: '',
tags: [],
...event,
pubkey: getPublicKey(privateKey),
} as any
rumor.id = getEventHash(rumor)
return rumor as Rumor
}
export function createSeal(rumor: Rumor, privateKey: Uint8Array, recipientPublicKey: string): NostrEvent {
return finalizeEvent(
{
kind: Seal,
content: nip44Encrypt(rumor, privateKey, recipientPublicKey),
created_at: randomNow(),
tags: [],
},
privateKey,
)
}
export function createWrap(seal: NostrEvent, recipientPublicKey: string): NostrEvent {
const randomKey = generateSecretKey()
return finalizeEvent(
{
kind: GiftWrap,
content: nip44Encrypt(seal, randomKey, recipientPublicKey),
created_at: randomNow(),
tags: [['p', recipientPublicKey]],
},
randomKey,
) as NostrEvent
}
export function wrapEvent(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientPublicKey: string,
): NostrEvent {
const rumor = createRumor(event, senderPrivateKey)
const seal = createSeal(rumor, senderPrivateKey, recipientPublicKey)
return createWrap(seal, recipientPublicKey)
}
export function wrapManyEvents(
event: Partial<UnsignedEvent>,
senderPrivateKey: Uint8Array,
recipientsPublicKeys: string[],
): NostrEvent[] {
if (!recipientsPublicKeys || recipientsPublicKeys.length === 0) {
throw new Error('At least one recipient is required.')
}
const senderPublicKey = getPublicKey(senderPrivateKey)
const wrappeds = [wrapEvent(event, senderPrivateKey, senderPublicKey)]
recipientsPublicKeys.forEach(recipientPublicKey => {
wrappeds.push(wrapEvent(event, senderPrivateKey, recipientPublicKey))
})
return wrappeds
}
export function unwrapEvent(wrap: NostrEvent, recipientPrivateKey: Uint8Array): Rumor {
const unwrappedSeal = nip44Decrypt(wrap, recipientPrivateKey)
return nip44Decrypt(unwrappedSeal, recipientPrivateKey)
}
export function unwrapManyEvents(wrappedEvents: NostrEvent[], recipientPrivateKey: Uint8Array): Rumor[] {
let unwrappedEvents: Rumor[] = []
wrappedEvents.forEach(e => {
unwrappedEvents.push(unwrapEvent(e, recipientPrivateKey))
})
unwrappedEvents.sort((a, b) => a.created_at - b.created_at)
return unwrappedEvents
}

View File

@@ -267,13 +267,11 @@ export async function readServerConfig(serverUrl: string): Promise<ServerConfigu
* @returns true if the object is a valid FileUploadResponse, otherwise false. * @returns true if the object is a valid FileUploadResponse, otherwise false.
*/ */
export function validateFileUploadResponse(response: any): response is FileUploadResponse { export function validateFileUploadResponse(response: any): response is FileUploadResponse {
if (typeof response !== 'object' || response === null) return false if (typeof response !== 'object' || response === null) {
if (!response.status || !response.message) {
return false return false
} }
if (response.status !== 'success' && response.status !== 'error' && response.status !== 'processing') { if (!['success', 'error', 'processing'].includes(response.status)) {
return false return false
} }
@@ -285,10 +283,8 @@ export function validateFileUploadResponse(response: any): response is FileUploa
return false return false
} }
if (response.processing_url) { if (response.processing_url && typeof response.processing_url !== 'string') {
if (typeof response.processing_url !== 'string') { return false
return false
}
} }
if (response.status === 'success' && !response.nip94_event) { if (response.status === 'success' && !response.nip94_event) {
@@ -296,25 +292,21 @@ export function validateFileUploadResponse(response: any): response is FileUploa
} }
if (response.nip94_event) { if (response.nip94_event) {
if ( const tags = response.nip94_event.tags as string[][]
!response.nip94_event.tags ||
!Array.isArray(response.nip94_event.tags) || if (!Array.isArray(tags)) {
response.nip94_event.tags.length === 0
) {
return false return false
} }
for (const tag of response.nip94_event.tags) { if (tags.some(t => t.length < 2 || t.some(x => typeof x !== 'string'))) {
if (!Array.isArray(tag) || tag.length !== 2) return false
if (typeof tag[0] !== 'string' || typeof tag[1] !== 'string') return false
}
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'url')) {
return false return false
} }
if (!(response.nip94_event.tags as string[]).find(t => t[0] === 'ox')) { if (!tags.some(t => t[0] === 'url')) {
return false
}
if (!tags.some(t => t[0] === 'ox')) {
return false return false
} }
} }
@@ -340,9 +332,6 @@ export async function uploadFile(
// Create FormData object // Create FormData object
const formData = new FormData() const formData = new FormData()
// Append the authorization header to HTML Form Data
formData.append('Authorization', nip98AuthorizationHeader)
// Append optional fields to FormData // Append optional fields to FormData
optionalFormDataFields && optionalFormDataFields &&
Object.entries(optionalFormDataFields).forEach(([key, value]) => { Object.entries(optionalFormDataFields).forEach(([key, value]) => {
@@ -359,7 +348,6 @@ export async function uploadFile(
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: nip98AuthorizationHeader, Authorization: nip98AuthorizationHeader,
'Content-Type': 'multipart/form-data',
}, },
body: formData, body: formData,
}) })
@@ -389,17 +377,13 @@ export async function uploadFile(
throw new Error('Unknown error in uploading file!') throw new Error('Unknown error in uploading file!')
} }
try { const parsedResponse = await response.json()
const parsedResponse = await response.json()
if (!validateFileUploadResponse(parsedResponse)) { if (!validateFileUploadResponse(parsedResponse)) {
throw new Error('Invalid response from the server!') throw new Error('Failed to validate upload response!')
}
return parsedResponse
} catch (error) {
throw new Error('Error parsing JSON response!')
} }
return parsedResponse
} }
/** /**
@@ -516,33 +500,28 @@ export async function checkFileProcessingStatus(
} }
// Parse the response // Parse the response
try { const parsedResponse = await response.json()
const parsedResponse = await response.json()
// 201 Created: Indicates the processing is over. // 201 Created: Indicates the processing is over.
if (response.status === 201) { if (response.status === 201) {
// Validate the response if (!validateFileUploadResponse(parsedResponse)) {
if (!validateFileUploadResponse(parsedResponse)) { throw new Error('Failed to validate upload response!')
throw new Error('Invalid response from the server!')
}
return parsedResponse
} }
// 200 OK: Indicates the processing is still ongoing. return parsedResponse as FileUploadResponse
if (response.status === 200) {
// Validate the response
if (!validateDelayedProcessingResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
}
throw new Error('Invalid response from the server!')
} catch (error) {
throw new Error('Error parsing JSON response!')
} }
// 200 OK: Indicates the processing is still ongoing.
if (response.status === 200) {
// Validate the response
if (!validateDelayedProcessingResponse(parsedResponse)) {
throw new Error('Invalid response from the server!')
}
return parsedResponse
}
throw new Error('Invalid response from the server!')
} }
/** /**

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.7.1", "version": "2.12.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -103,6 +103,11 @@
"require": "./lib/cjs/nip13.js", "require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts" "types": "./lib/types/nip13.d.ts"
}, },
"./nip17": {
"import": "./lib/esm/nip17.js",
"require": "./lib/cjs/nip17.js",
"types": "./lib/types/nip17.d.ts"
},
"./nip18": { "./nip18": {
"import": "./lib/esm/nip18.js", "import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js", "require": "./lib/cjs/nip18.js",
@@ -168,11 +173,21 @@
"require": "./lib/cjs/nip49.js", "require": "./lib/cjs/nip49.js",
"types": "./lib/types/nip49.d.ts" "types": "./lib/types/nip49.d.ts"
}, },
"./nip54": {
"import": "./lib/esm/nip54.js",
"require": "./lib/cjs/nip54.js",
"types": "./lib/types/nip54.d.ts"
},
"./nip57": { "./nip57": {
"import": "./lib/esm/nip57.js", "import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js", "require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts" "types": "./lib/types/nip57.d.ts"
}, },
"./nip59": {
"import": "./lib/esm/nip59.js",
"require": "./lib/cjs/nip59.js",
"types": "./lib/types/nip59.d.ts"
},
"./nip58": { "./nip58": {
"import": "./lib/esm/nip58.js", "import": "./lib/esm/nip58.js",
"require": "./lib/cjs/nip58.js", "require": "./lib/cjs/nip58.js",
@@ -224,7 +239,7 @@
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"nostr-wasm": "v0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
@@ -259,7 +274,7 @@
"msw": "^2.1.4", "msw": "^2.1.4",
"node-fetch": "^2.6.9", "node-fetch": "^2.6.9",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"typescript": "^5.0.4" "typescript": "^5.8.2"
}, },
"scripts": { "scripts": {
"prepublish": "just build" "prepublish": "just build"

View File

@@ -205,3 +205,33 @@ test('get()', async () => {
expect(event).not.toBeNull() expect(event).not.toBeNull()
expect(event).toHaveProperty('id', ids[0]) expect(event).toHaveProperty('id', ids[0])
}) })
test('track relays when publishing', async () => {
let event1 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
let event2 = finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
)
pool.trackRelays = true
await Promise.all(pool.publish(relayURLs, event1))
expect(pool.seenOn.get(event1.id)).toBeDefined()
expect(Array.from(pool.seenOn.get(event1.id)!).map(r => r.url)).toEqual(expect.arrayContaining(relayURLs))
pool.trackRelays = false
await Promise.all(pool.publish(relayURLs, event2))
expect(pool.seenOn.get(event2.id)).toBeUndefined()
})

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test' import { expect, test } from 'bun:test'
import { Server } from 'mock-socket'
import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts' import { finalizeEvent, generateSecretKey, getPublicKey } from './pure.ts'
import { Relay, useWebSocketImplementation } from './relay.ts' import { Relay, useWebSocketImplementation } from './relay.ts'
import { MockRelay, MockWebSocketClient } from './test-helpers.ts' import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
@@ -92,3 +92,28 @@ test('listening and publishing and closing', async done => {
), ),
) )
}) })
test('publish timeout', async () => {
const url = 'wss://relay.example.com'
new Server(url)
const relay = new Relay(url)
relay.publishTimeout = 100
await relay.connect()
setTimeout(() => relay.close(), 20000) // close the relay to fail the test on timeout
expect(
relay.publish(
finalizeEvent(
{
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
},
generateSecretKey(),
),
),
).rejects.toThrow('publish timed out')
})