Compare commits

..

2 Commits

Author SHA1 Message Date
fiatjaf
52cd6490fe max keys 256. 2023-04-04 12:33:23 -03:00
fiatjaf
3248b8b166 add nip41: stateless revocations. 2023-04-04 12:33:20 -03:00
58 changed files with 974 additions and 5908 deletions

View File

@@ -2,7 +2,7 @@
"root": true, "root": true,
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"], "plugins": ["@typescript-eslint"],
"parserOptions": { "parserOptions": {
"ecmaVersion": 9, "ecmaVersion": 9,
@@ -18,8 +18,11 @@
"node": true "node": true
}, },
"plugins": ["babel"],
"globals": { "globals": {
"document": false, "document": false,
"BigInt": false,
"navigator": false, "navigator": false,
"window": false, "window": false,
"crypto": false, "crypto": false,
@@ -101,6 +104,7 @@
"no-octal-escape": 2, "no-octal-escape": 2,
"no-path-concat": 0, "no-path-concat": 0,
"no-proto": 2, "no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2, "no-regex-spaces": 2,
"no-return-assign": 0, "no-return-assign": 0,
"no-self-assign": 2, "no-self-assign": 2,
@@ -150,13 +154,5 @@
"wrap-iife": [2, "any"], "wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"], "yield-star-spacing": [2, "both"],
"yoda": [0] "yoda": [0]
}, }
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest/globals": true },
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
} }

View File

@@ -15,4 +15,5 @@ jobs:
node-version: 18 node-version: 18
- uses: extractions/setup-just@v1 - uses: extractions/setup-just@v1
- run: just install-dependencies - run: just install-dependencies
- run: just build
- run: just test - run: just test

24
LICENSE
View File

@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@@ -4,8 +4,6 @@ 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).
## Installation ## Installation
```bash ```bash
@@ -29,7 +27,7 @@ let pk = getPublicKey(sk) // `pk` is a hex string
import { import {
validateEvent, validateEvent,
verifySignature, verifySignature,
getSignature, signEvent,
getEventHash, getEventHash,
getPublicKey getPublicKey
} from 'nostr-tools' } from 'nostr-tools'
@@ -43,7 +41,7 @@ let event = {
} }
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = getSignature(event, privateKey) event.sig = signEvent(event, privateKey)
let ok = validateEvent(event) let ok = validateEvent(event)
let veryOk = verifySignature(event) let veryOk = verifySignature(event)
@@ -57,7 +55,7 @@ import {
generatePrivateKey, generatePrivateKey,
getPublicKey, getPublicKey,
getEventHash, getEventHash,
getSignature signEvent
} from 'nostr-tools' } from 'nostr-tools'
const relay = relayInit('wss://relay.example.com') const relay = relayInit('wss://relay.example.com')
@@ -106,7 +104,7 @@ let event = {
content: 'hello world' content: 'hello world'
} }
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = getSignature(event, sk) event.sig = signEvent(event, sk)
let pub = relay.publish(event) let pub = relay.publish(event)
pub.on('ok', () => { pub.on('ok', () => {
@@ -268,7 +266,7 @@ sendEvent(event)
// on the receiver side // on the receiver side
sub.on('event', event => { sub.on('event', event => {
let sender = event.pubkey let sender = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
pk1 === sender pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content) let plaintext = await nip04.decrypt(sk2, pk1, event.content)
}) })
@@ -327,4 +325,4 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
## License ## License
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain. Public domain.

View File

@@ -1,14 +1,14 @@
import { const {
getBlankEvent, getBlankEvent,
finishEvent, finishEvent,
serializeEvent, serializeEvent,
getEventHash, getEventHash,
validateEvent, validateEvent,
verifySignature, verifySignature,
getSignature, signEvent,
Kind, getPublicKey,
} from './event.ts' Kind
import {getPublicKey} from './keys.ts' } = require('./lib/nostr.cjs')
describe('Event', () => { describe('Event', () => {
describe('getBlankEvent', () => { describe('getBlankEvent', () => {
@@ -20,15 +20,6 @@ describe('Event', () => {
created_at: 0 created_at: 0
}) })
}) })
it('should return a blank event object with defined kind', () => {
expect(getBlankEvent(Kind.Text)).toEqual({
kind: 1,
content: '',
tags: [],
created_at: 0
})
})
}) })
describe('finishEvent', () => { describe('finishEvent', () => {
@@ -97,7 +88,6 @@ describe('Event', () => {
} }
expect(() => { expect(() => {
// @ts-expect-error
serializeEvent(invalidEvent) serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties") }).toThrow("can't serialize event with wrong or missing properties")
}) })
@@ -291,8 +281,8 @@ describe('Event', () => {
}) })
}) })
describe('getSignature', () => { describe('signEvent', () => {
it('should produce the correct signature for an event object', () => { it('should sign an event object', () => {
const privateKey = const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf' 'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey) const publicKey = getPublicKey(privateKey)
@@ -305,10 +295,9 @@ describe('Event', () => {
pubkey: publicKey pubkey: publicKey
} }
const sig = getSignature(unsignedEvent, privateKey) const sig = signEvent(unsignedEvent, privateKey)
// verify the signature // verify the signature
// @ts-expect-error
const isValid = verifySignature({ const isValid = verifySignature({
...unsignedEvent, ...unsignedEvent,
sig sig
@@ -335,10 +324,9 @@ describe('Event', () => {
pubkey: publicKey pubkey: publicKey
} }
const sig = getSignature(unsignedEvent, wrongPrivateKey) const sig = signEvent(unsignedEvent, wrongPrivateKey)
// verify the signature // verify the signature
// @ts-expect-error
const isValid = verifySignature({ const isValid = verifySignature({
...unsignedEvent, ...unsignedEvent,
sig sig

View File

@@ -1,9 +1,8 @@
import {schnorr} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256' import {sha256} from '@noble/hashes/sha256'
import {bytesToHex} from '@noble/hashes/utils'
import {getPublicKey} from './keys.ts' import {utf8Encoder} from './utils'
import {utf8Encoder} from './utils.ts' import {getPublicKey} from './keys'
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
export enum Kind { export enum Kind {
@@ -13,64 +12,55 @@ export enum Kind {
Contacts = 3, Contacts = 3,
EncryptedDirectMessage = 4, EncryptedDirectMessage = 4,
EventDeletion = 5, EventDeletion = 5,
Repost = 6,
Reaction = 7, Reaction = 7,
BadgeAward = 8, StatelessRevocation = 13,
ChannelCreation = 40, ChannelCreation = 40,
ChannelMetadata = 41, ChannelMetadata = 41,
ChannelMessage = 42, ChannelMessage = 42,
ChannelHideMessage = 43, ChannelHideMessage = 43,
ChannelMuteUser = 44, ChannelMuteUser = 44,
Blank = 255,
Report = 1984, Report = 1984,
ZapRequest = 9734, ZapRequest = 9734,
Zap = 9735, Zap = 9735,
RelayList = 10002, RelayList = 10002,
ClientAuth = 22242, ClientAuth = 22242,
ProfileBadge = 30008,
BadgeDefinition = 30009,
Article = 30023 Article = 30023
} }
export type EventTemplate<K extends number = Kind> = { export type EventTemplate = {
kind: K kind: Kind
tags: string[][] tags: string[][]
content: string content: string
created_at: number created_at: number
} }
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & { export type UnsignedEvent = EventTemplate & {
pubkey: string pubkey: string
} }
export type Event<K extends number = Kind> = UnsignedEvent<K> & { export type Event = UnsignedEvent & {
id: string id: string
sig: string sig: string
} }
export function getBlankEvent(): EventTemplate<Kind.Blank> export function getBlankEvent(): EventTemplate {
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
return { return {
kind, kind: 255,
content: '', content: '',
tags: [], tags: [],
created_at: 0 created_at: 0
} }
} }
export function finishEvent<K extends number = Kind>( export function finishEvent(t: EventTemplate, privateKey: string): Event {
t: EventTemplate<K>, let event = t as Event
privateKey: string
): Event<K> {
let event = t as Event<K>
event.pubkey = getPublicKey(privateKey) event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event) event.id = getEventHash(event)
event.sig = getSignature(event, privateKey) event.sig = signEvent(event, privateKey)
return event return event
} }
export function serializeEvent(evt: UnsignedEvent<number>): string { export function serializeEvent(evt: UnsignedEvent): string {
if (!validateEvent(evt)) if (!validateEvent(evt))
throw new Error("can't serialize event with wrong or missing properties") throw new Error("can't serialize event with wrong or missing properties")
@@ -84,16 +74,13 @@ export function serializeEvent(evt: UnsignedEvent<number>): string {
]) ])
} }
export function getEventHash(event: UnsignedEvent<number>): string { export function getEventHash(event: UnsignedEvent): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event))) let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash) return secp256k1.utils.bytesToHex(eventHash)
} }
const isRecord = (obj: unknown): obj is Record<string, unknown> => export function validateEvent(event: UnsignedEvent): boolean {
obj instanceof Object if (typeof event !== 'object') return false
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false if (typeof event.created_at !== 'number') return false
@@ -112,26 +99,16 @@ export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
return true return true
} }
export function verifySignature(event: Event<number>): boolean { export function verifySignature(event: Event & {sig: string}): boolean {
try { return secp256k1.schnorr.verifySync(
return schnorr.verify(event.sig, getEventHash(event), event.pubkey) event.sig,
} catch (err) { getEventHash(event),
return false event.pubkey
}
}
/** @deprecated Use `getSignature` instead. */
export function signEvent(event: UnsignedEvent<number>, key: string): string {
console.warn(
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.'
) )
return getSignature(event, key)
} }
/** Calculate the signature for an event. */ export function signEvent(event: UnsignedEvent, key: string): string {
export function getSignature( return secp256k1.utils.bytesToHex(
event: UnsignedEvent<number>, secp256k1.schnorr.signSync(getEventHash(event), key)
key: string )
): string {
return bytesToHex(schnorr.sign(getEventHash(event), key))
} }

View File

@@ -1,15 +1,17 @@
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts' /* eslint-env jest */
const {fj} = require('./lib/nostr.cjs')
test('match id', () => { test('match id', () => {
expect( expect(
matchEventId( fj.matchEventId(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146' 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
) )
).toBeTruthy() ).toBeTruthy()
expect( expect(
matchEventId( fj.matchEventId(
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`, `["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146' 'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
) )
@@ -18,14 +20,14 @@ test('match id', () => {
test('match kind', () => { test('match kind', () => {
expect( expect(
matchEventKind( fj.matchEventKind(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`, `["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
1 1
) )
).toBeTruthy() ).toBeTruthy()
expect( expect(
matchEventKind( fj.matchEventKind(
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`, `["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
12720 12720
) )
@@ -33,14 +35,14 @@ test('match kind', () => {
}) })
test('match subscription id', () => { test('match subscription id', () => {
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('') expect(fj.getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_') expect(fj.getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname') expect(fj.getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual( expect(fj.getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
'kasjbdjkav' 'kasjbdjkav'
) )
expect( expect(
getSubscriptionId( fj.getSubscriptionId(
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]' ' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
) )
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH') ).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')

View File

@@ -1,5 +1,6 @@
import {matchFilter, matchFilters, mergeFilters} from './filter.ts' /* eslint-env jest */
import {buildEvent} from './test-helpers.ts'
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
describe('Filter', () => { describe('Filter', () => {
describe('matchFilter', () => { describe('matchFilter', () => {
@@ -13,13 +14,13 @@ describe('Filter', () => {
'#tag': ['value'] '#tag': ['value']
} }
const event = buildEvent({ const event = {
id: '123', id: '123',
kind: 1, kind: 1,
pubkey: 'abc', pubkey: 'abc',
created_at: 150, created_at: 150,
tags: [['tag', 'value']] tags: [['tag', 'value']]
}) }
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -29,27 +30,17 @@ describe('Filter', () => {
it('should return false when the event id is not in the filter', () => { it('should return false when the event id is not in the filter', () => {
const filter = {ids: ['123', '456']} const filter = {ids: ['123', '456']}
const event = buildEvent({id: '789'}) const event = {id: '789'}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
expect(result).toEqual(false) expect(result).toEqual(false)
}) })
it('should return true when the event id starts with a prefix', () => {
const filter = {ids: ['22', '00']}
const event = buildEvent({id: '001'})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event kind is not in the filter', () => { it('should return false when the event kind is not in the filter', () => {
const filter = {kinds: [1, 2, 3]} const filter = {kinds: [1, 2, 3]}
const event = buildEvent({kind: 4}) const event = {kind: 4}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -59,7 +50,7 @@ describe('Filter', () => {
it('should return false when the event author is not in the filter', () => { it('should return false when the event author is not in the filter', () => {
const filter = {authors: ['abc', 'def']} const filter = {authors: ['abc', 'def']}
const event = buildEvent({pubkey: 'ghi'}) const event = {pubkey: 'ghi'}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -69,7 +60,7 @@ describe('Filter', () => {
it('should return false when a tag is not present in the event', () => { it('should return false when a tag is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']} const filter = {'#tag': ['value1', 'value2']}
const event = buildEvent({tags: [['not_tag', 'value1']]}) const event = {tags: [['not_tag', 'value1']]}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -79,7 +70,7 @@ describe('Filter', () => {
it('should return false when a tag value is not present in the event', () => { it('should return false when a tag value is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']} const filter = {'#tag': ['value1', 'value2']}
const event = buildEvent({tags: [['tag', 'value3']]}) const event = {tags: [['tag', 'value3']]}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -89,7 +80,7 @@ describe('Filter', () => {
it('should return true when filter has tags that is present in the event', () => { it('should return true when filter has tags that is present in the event', () => {
const filter = {'#tag1': ['foo']} const filter = {'#tag1': ['foo']}
const event = buildEvent({ const event = {
id: '123', id: '123',
kind: 1, kind: 1,
pubkey: 'abc', pubkey: 'abc',
@@ -98,7 +89,7 @@ describe('Filter', () => {
['tag1', 'foo'], ['tag1', 'foo'],
['tag2', 'bar'] ['tag2', 'bar']
] ]
}) }
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -108,7 +99,7 @@ describe('Filter', () => {
it('should return false when the event is before the filter since value', () => { it('should return false when the event is before the filter since value', () => {
const filter = {since: 100} const filter = {since: 100}
const event = buildEvent({created_at: 50}) const event = {created_at: 50}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -118,7 +109,7 @@ describe('Filter', () => {
it('should return false when the event is after the filter until value', () => { it('should return false when the event is after the filter until value', () => {
const filter = {until: 100} const filter = {until: 100}
const event = buildEvent({created_at: 150}) const event = {created_at: 150}
const result = matchFilter(filter, event) const result = matchFilter(filter, event)
@@ -134,21 +125,7 @@ describe('Filter', () => {
{ids: ['789'], kinds: [3], authors: ['ghi']} {ids: ['789'], kinds: [3], authors: ['ghi']}
] ]
const event = buildEvent({id: '789', kind: 3, pubkey: 'ghi'}) const event = {id: '789', kind: 3, pubkey: 'ghi'}
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return true when at least one prefix matches the event', () => {
const filters = [
{ids: ['1'], kinds: [1], authors: ['a']},
{ids: ['4'], kinds: [2], authors: ['d']},
{ids: ['9'], kinds: [3], authors: ['g']}
]
const event = buildEvent({id: '987', kind: 3, pubkey: 'ghi'})
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -162,12 +139,7 @@ describe('Filter', () => {
{authors: ['abc'], limit: 3} {authors: ['abc'], limit: 3}
] ]
const event = buildEvent({ const event = {id: '123', kind: 1, pubkey: 'abc', created_at: 150}
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150
})
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -181,7 +153,7 @@ describe('Filter', () => {
{ids: ['789'], kinds: [3], authors: ['ghi']} {ids: ['789'], kinds: [3], authors: ['ghi']}
] ]
const event = buildEvent({id: '100', kind: 4, pubkey: 'jkl'}) const event = {id: '100', kind: 4, pubkey: 'jkl'}
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
@@ -194,35 +166,11 @@ describe('Filter', () => {
{kinds: [1], limit: 2}, {kinds: [1], limit: 2},
{authors: ['abc'], limit: 3} {authors: ['abc'], limit: 3}
] ]
const event = buildEvent({ const event = {id: '456', kind: 2, pubkey: 'def', created_at: 200}
id: '456',
kind: 2,
pubkey: 'def',
created_at: 200
})
const result = matchFilters(filters, event) const result = matchFilters(filters, event)
expect(result).toEqual(false) expect(result).toEqual(false)
}) })
}) })
describe('mergeFilters', () => {
it('should merge filters', () => {
expect(
mergeFilters(
{ids: ['a', 'b'], limit: 3},
{authors: ['x'], ids: ['b', 'c']}
)
).toEqual({ids: ['a', 'b', 'c'], limit: 3, authors: ['x']})
expect(
mergeFilters(
{kinds: [1], since: 15, until: 30},
{since: 10, kinds: [7], until: 15},
{kinds: [9, 10]}
)
).toEqual({kinds: [1, 7, 9, 10], since: 10, until: 30})
})
})
}) })

View File

@@ -1,8 +1,8 @@
import {Event, type Kind} from './event.ts' import {Event} from './event'
export type Filter<K extends number = Kind> = { export type Filter = {
ids?: string[] ids?: string[]
kinds?: K[] kinds?: number[]
authors?: string[] authors?: string[]
since?: number since?: number
until?: number until?: number
@@ -12,20 +12,13 @@ export type Filter<K extends number = Kind> = {
} }
export function matchFilter( export function matchFilter(
filter: Filter<number>, filter: Filter,
event: Event<number> event: Event & {id: string}
): boolean { ): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) { if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false
}
}
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) { if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) { return false
return false
}
}
for (let f in filter) { for (let f in filter) {
if (f[0] === '#') { if (f[0] === '#') {
@@ -48,45 +41,11 @@ export function matchFilter(
} }
export function matchFilters( export function matchFilters(
filters: Filter<number>[], filters: Filter[],
event: Event<number> event: Event & {id: string}
): boolean { ): boolean {
for (let i = 0; i < filters.length; i++) { for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true if (matchFilter(filters[i], event)) return true
} }
return false return false
} }
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
let result: Filter<number> = {}
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {
if (
property === 'kinds' ||
property === 'ids' ||
property === 'authors' ||
property[0] === '#'
) {
// @ts-ignore
result[property] = result[property] || []
// @ts-ignore
for (let v = 0; v < values.length; v++) {
// @ts-ignore
let value = values[v]
// @ts-ignore
if (!result[property].includes(value)) result[property].push(value)
}
}
})
if (filter.limit && (!result.limit || filter.limit > result.limit))
result.limit = filter.limit
if (filter.until && (!result.until || filter.until > result.until))
result.until = filter.until
if (filter.since && (!result.since || filter.since < result.since))
result.since = filter.since
}
return result
}

View File

@@ -1,24 +1,28 @@
export * from './keys.ts' export * from './keys'
export * from './relay.ts' export * from './relay'
export * from './event.ts' export * from './event'
export * from './filter.ts' export * from './filter'
export * from './pool.ts' export * from './pool'
export * from './references.ts' export * from './references'
export * as nip04 from './nip04.ts' export * as nip04 from './nip04'
export * as nip05 from './nip05.ts' export * as nip05 from './nip05'
export * as nip06 from './nip06.ts' export * as nip06 from './nip06'
export * as nip10 from './nip10.ts' export * as nip10 from './nip10'
export * as nip13 from './nip13.ts' export * as nip19 from './nip19'
export * as nip18 from './nip18.ts' export * as nip26 from './nip26'
export * as nip19 from './nip19.ts' export * as nip39 from './nip39'
export * as nip21 from './nip21.ts' export * as nip41 from './nip41'
export * as nip25 from './nip25.ts' export * as nip57 from './nip57'
export * as nip26 from './nip26.ts'
export * as nip27 from './nip27.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip57 from './nip57.ts'
export * as fj from './fakejson.ts' export * as fj from './fakejson'
export * as utils from './utils.ts' export * as utils from './utils'
// monkey patch secp256k1
import * as secp256k1 from '@noble/secp256k1'
import {hmac} from '@noble/hashes/hmac'
import {sha256} from '@noble/hashes/sha256'
secp256k1.utils.hmacSha256Sync = (key, ...msgs) =>
hmac(sha256, key, secp256k1.utils.concatBytes(...msgs))
secp256k1.utils.sha256Sync = (...msgs) =>
sha256(secp256k1.utils.concatBytes(...msgs))

View File

@@ -1,5 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@@ -7,10 +7,10 @@ build:
rm -rf lib rm -rf lib
node build.js node build.js
test: test: build
jest jest
test-only file: test-only file: build
jest {{file}} jest {{file}}
emit-types: emit-types:

View File

@@ -1,14 +1,16 @@
import {generatePrivateKey, getPublicKey} from './keys.ts' /* eslint-env jest */
test('private key generation', () => { const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('test private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/) expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
}) })
test('public key generation', () => { test('test public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/) expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
}) })
test('public key from private key deterministic', () => { test('test public key from private key deterministic', () => {
let sk = generatePrivateKey() let sk = generatePrivateKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)

View File

@@ -1,10 +1,9 @@
import {schnorr} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {bytesToHex} from '@noble/hashes/utils'
export function generatePrivateKey(): string { export function generatePrivateKey(): string {
return bytesToHex(schnorr.utils.randomPrivateKey()) return secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
} }
export function getPublicKey(privateKey: string): string { export function getPublicKey(privateKey: string): string {
return bytesToHex(schnorr.getPublicKey(privateKey)) return secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKey))
} }

15
nip04.test.js Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-env jest */
globalThis.crypto = require('crypto')
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

View File

@@ -1,19 +0,0 @@
import crypto from 'node:crypto'
import {encrypt, decrypt} from './nip04.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

View File

@@ -1,14 +1,8 @@
import {randomBytes} from '@noble/hashes/utils' import {randomBytes} from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {base64} from '@scure/base' import {base64} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts' import {utf8Decoder, utf8Encoder} from './utils'
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt( export async function encrypt(
privkey: string, privkey: string,

20
nip05.test.js Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip05} = require('./lib/nostr.cjs')
test('fetch nip05 profiles', async () => {
nip05.useFetchImplementation(fetch)
let p1 = await nip05.queryProfile('jb55.com')
expect(p1.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1.relays).toEqual(['wss://relay.damus.io'])
let p2 = await nip05.queryProfile('jb55@jb55.com')
expect(p2.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
})

View File

@@ -1,38 +0,0 @@
import fetch from 'node-fetch'
import {useFetchImplementation, queryProfile} from './nip05.ts'
test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch)
let p1 = await queryProfile('jb55.com')
expect(p1!.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com')
expect(p2!.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('channel.ninja@channel.ninja')
expect(p3!.pubkey).toEqual(
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
)
expect(p3!.relays).toEqual(undefined)
let p4 = await queryProfile('_@fiatjaf.com')
expect(p4!.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(p4!.relays).toEqual([
'wss://relay.nostr.bg',
'wss://nos.lol',
'wss://nostr-verified.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
])
})

View File

@@ -1,13 +1,4 @@
import {ProfilePointer} from './nip19.ts' import {ProfilePointer} from './nip19'
/**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
*
* - 0: full match
* - 1: name (optional)
* - 2: domain
*/
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
var _fetch: any var _fetch: any
@@ -34,53 +25,36 @@ export async function searchDomain(
} }
} }
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> { export async function queryProfile(
const match = fullname.match(NIP05_REGEX) fullname: string
if (!match) return null ): Promise<ProfilePointer | null> {
let [name, domain] = fullname.split('@')
const [_, name = '_', domain] = match if (!domain) {
// if there is no @, it is because it is just a domain, so assume the name is "_"
domain = name
name = '_'
}
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
if (!domain.includes('.')) return null
let res
try { try {
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`) res = await (
const { names, relays } = parseNIP05Result(await res.json()) await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
const pubkey = names[name] } catch (err) {
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
} catch (_e) {
return null return null
} }
}
/** nostr.json result. */ if (!res?.names?.[name]) return null
export interface NIP05Result {
names: { let pubkey = res.names[name] as string
[name: string]: string let relays = (res.relays?.[pubkey] || []) as string[] // nip35
}
relays?: { return {
[pubkey: string]: string[] pubkey,
relays
} }
} }
/** Parse the nostr.json and throw if it's not valid. */
function parseNIP05Result(json: any): NIP05Result {
const result: NIP05Result = {
names: {},
}
for (const [name, pubkey] of Object.entries(json.names)) {
if (typeof name === 'string' && typeof pubkey === 'string') {
result.names[name] = pubkey
}
}
if (json.relays) {
result.relays = {}
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === 'string' && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
}
}
}
return result
}

View File

@@ -1,8 +1,9 @@
import {privateKeyFromSeedWords} from './nip06.ts' /* eslint-env jest */
const {nip06} = require('./lib/nostr.cjs')
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 = nip06.privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual( expect(privateKey).toEqual(
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2' 'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
) )
@@ -11,7 +12,7 @@ test('generate private key from a mnemonic', async () => {
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 = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual( expect(privateKey).toEqual(
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4' '55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
) )

View File

@@ -1,4 +1,4 @@
import {bytesToHex} from '@noble/hashes/utils' import * as secp256k1 from '@noble/secp256k1'
import {wordlist} from '@scure/bip39/wordlists/english.js' import {wordlist} from '@scure/bip39/wordlists/english.js'
import { import {
generateMnemonic, generateMnemonic,
@@ -14,7 +14,7 @@ export function privateKeyFromSeedWords(
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase)) let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey let privateKey = root.derive(`m/44'/1237'/0'/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 secp256k1.utils.bytesToHex(privateKey)
} }
export function generateSeedWords(): string { export function generateSeedWords(): string {

View File

@@ -1,4 +1,6 @@
import {parse} from './nip10.ts' /* eslint-env jest */
const {nip10} = require('./lib/nostr.cjs')
describe('parse NIP10-referenced events', () => { describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => { test('legacy + a lot of events', () => {
@@ -47,7 +49,7 @@ describe('parse NIP10-referenced events', () => {
] ]
} }
expect(parse(event)).toEqual({ expect(nip10.parse(event)).toEqual({
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -128,7 +130,7 @@ describe('parse NIP10-referenced events', () => {
] ]
} }
expect(parse(event)).toEqual({ expect(nip10.parse(event)).toEqual({
mentions: [ mentions: [
{ {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631', id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
@@ -189,7 +191,7 @@ describe('parse NIP10-referenced events', () => {
] ]
} }
expect(parse(event)).toEqual({ expect(nip10.parse(event)).toEqual({
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
@@ -233,7 +235,7 @@ describe('parse NIP10-referenced events', () => {
] ]
} }
expect(parse(event)).toEqual({ expect(nip10.parse(event)).toEqual({
mentions: [], mentions: [],
profiles: [ profiles: [
{ {
@@ -302,7 +304,7 @@ describe('parse NIP10-referenced events', () => {
] ]
} }
expect(parse(event)).toEqual({ expect(nip10.parse(event)).toEqual({
mentions: [], mentions: [],
profiles: [ profiles: [
{ {

View File

@@ -1,5 +1,5 @@
import type {Event} from './event.ts' import type {Event} from './event'
import type {EventPointer, ProfilePointer} from './nip19.ts' import type {EventPointer, ProfilePointer} from './nip19'
export type NIP10Result = { export type NIP10Result = {
/** /**

View File

@@ -1,7 +0,0 @@
import {getPow} from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = getPow(id)
expect(difficulty).toEqual(21)
})

View File

@@ -1,42 +0,0 @@
import {hexToBytes} from '@noble/hashes/utils'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(id: string): number {
return getLeadingZeroBits(hexToBytes(id))
}
/**
* Get number of leading 0 bits. Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function getLeadingZeroBits(hash: Uint8Array): number {
let total: number, i: number, bits: number
for (i = 0, total = 0; i < hash.length; i++) {
bits = msb(hash[i])
total += bits
if (bits !== 8) {
break
}
}
return total
}
/**
* Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function msb(b: number) {
let n = 0
if (b === 0) {
return 8
}
// eslint-disable-next-line no-cond-assign
while (b >>= 1) {
n++
}
return 7 - n
}

View File

@@ -1,112 +0,0 @@
import {finishEvent, Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {finishRepostEvent, getRepostedEventPointer, getRepostedEvent} from './nip18.ts'
import {buildEvent} from './test-helpers.ts'
const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const repostedEvent = finishEvent(
{
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey']
],
content: 'Replied to a post',
created_at: 1617932115
},
privateKey
)
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115
}
const event = finishRepostEvent(
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey]
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
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)
})
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
created_at: 1617932115
}
const event = finishRepostEvent(
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey]
])
expect(event.content).toEqual('')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
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(undefined)
})
})
describe('getRepostedEventPointer', () => {
it('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Kind.Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toEqual(undefined)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})

View File

@@ -1,97 +0,0 @@
import {Event, finishEvent, Kind, verifySignature} from './event.ts'
import {EventPointer} from './nip19.ts'
export type RepostEventTemplate = {
/**
* Pass only non-nip18 tags if you have to.
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
*/
tags?: string[][]
/**
* Pass an empty string to NOT include the stringified JSON of the reposted event.
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
* @default Stringified JSON of the reposted event
*/
content?: '';
created_at: number
}
export function finishRepostEvent(
t: RepostEventTemplate,
reposted: Event<number>,
relayUrl: string,
privateKey: string,
): Event<Kind.Repost> {
return finishEvent({
kind: Kind.Repost,
tags: [
...(t.tags ?? []),
[ 'e', reposted.id, relayUrl ],
[ 'p', reposted.pubkey ],
],
content: t.content === '' ? '' : JSON.stringify(reposted),
created_at: t.created_at,
}, privateKey)
}
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Repost) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [ lastETag[2], lastPTag?.[2] ].filter((x): x is string => typeof x === 'string'),
author: lastPTag?.[1],
}
}
export type GetRepostedEventOptions = {
skipVerification?: boolean,
};
export function getRepostedEvent(event: Event<number>, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event<number> {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event<number>
try {
repostedEvent = JSON.parse(event.content) as Event<number>
} catch (error) {
return undefined
}
if (repostedEvent.id !== pointer.id) {
return undefined
}
if (!skipVerification && !verifySignature(repostedEvent)) {
return undefined
}
return repostedEvent
}

View File

@@ -1,29 +1,21 @@
import {generatePrivateKey, getPublicKey} from './keys.ts' /* eslint-env jest */
import {
decode, const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
naddrEncode,
nprofileEncode,
npubEncode,
nrelayEncode,
nsecEncode,
type AddressPointer,
type ProfilePointer,
} from './nip19.ts'
test('encode and decode nsec', () => { test('encode and decode nsec', () => {
let sk = generatePrivateKey() let sk = generatePrivateKey()
let nsec = nsecEncode(sk) let nsec = nip19.nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/) expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = decode(nsec) let {type, data} = nip19.decode(nsec)
expect(type).toEqual('nsec') expect(type).toEqual('nsec')
expect(data).toEqual(sk) expect(data).toEqual(sk)
}) })
test('encode and decode npub', () => { test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey()) let pk = getPublicKey(generatePrivateKey())
let npub = npubEncode(pk) let npub = nip19.npubEncode(pk)
expect(npub).toMatch(/npub1\w+/) expect(npub).toMatch(/npub1\w+/)
let {type, data} = decode(npub) let {type, data} = nip19.decode(npub)
expect(type).toEqual('npub') expect(type).toEqual('npub')
expect(data).toEqual(pk) expect(data).toEqual(pk)
}) })
@@ -34,20 +26,19 @@ test('encode and decode nprofile', () => {
'wss://relay.nostr.example.mydomain.example.com', 'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com' 'wss://nostr.banana.com'
] ]
let nprofile = nprofileEncode({pubkey: pk, relays}) let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/) expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = decode(nprofile) let {type, data} = nip19.decode(nprofile)
expect(type).toEqual('nprofile') expect(type).toEqual('nprofile')
const pointer = data as ProfilePointer expect(data.pubkey).toEqual(pk)
expect(pointer.pubkey).toEqual(pk) expect(data.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[0]) expect(data.relays).toContain(relays[1])
expect(pointer.relays).toContain(relays[1])
}) })
test('decode nprofile without relays', () => { test('decode nprofile without relays', () => {
expect( expect(
decode( nip19.decode(
nprofileEncode({ nip19.nprofileEncode({
pubkey: pubkey:
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322', '97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
relays: [] relays: []
@@ -65,59 +56,47 @@ test('encode and decode naddr', () => {
'wss://relay.nostr.example.mydomain.example.com', 'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com' 'wss://nostr.banana.com'
] ]
let naddr = naddrEncode({ let naddr = nip19.naddrEncode({
pubkey: pk, pubkey: pk,
relays, relays,
kind: 30023, kind: 30023,
identifier: 'banana' identifier: 'banana'
}) })
expect(naddr).toMatch(/naddr1\w+/) expect(naddr).toMatch(/naddr1\w+/)
let {type, data} = decode(naddr) let {type, data} = nip19.decode(naddr)
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer expect(data.pubkey).toEqual(pk)
expect(pointer.pubkey).toEqual(pk) expect(data.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[0]) expect(data.relays).toContain(relays[1])
expect(pointer.relays).toContain(relays[1]) expect(data.kind).toEqual(30023)
expect(pointer.kind).toEqual(30023) expect(data.identifier).toEqual('banana')
expect(pointer.identifier).toEqual('banana')
}) })
test('decode naddr from habla.news', () => { test('decode naddr from habla.news', () => {
let {type, data} = decode( let {type, data} = nip19.decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5' 'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
) )
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer expect(data.pubkey).toEqual(
expect(pointer.pubkey).toEqual(
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194' '7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
) )
expect(pointer.kind).toEqual(30023) expect(data.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references') expect(data.identifier).toEqual('references')
}) })
test('decode naddr from go-nostr with different TLV ordering', () => { test('decode naddr from go-nostr with different TLV ordering', () => {
let {type, data} = decode( let {type, data} = nip19.decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx' 'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
) )
expect(type).toEqual('naddr') expect(type).toEqual('naddr')
const pointer = data as AddressPointer expect(data.pubkey).toEqual(
expect(pointer.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d' '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
) )
expect(pointer.relays).toContain( expect(data.relays).toContain(
'wss://relay.nostr.example.mydomain.example.com' 'wss://relay.nostr.example.mydomain.example.com'
) )
expect(pointer.relays).toContain('wss://nostr.banana.com') expect(data.relays).toContain('wss://nostr.banana.com')
expect(pointer.kind).toEqual(30023) expect(data.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana') expect(data.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = 'wss://relay.nostr.example'
let nrelay = nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
}) })

View File

@@ -1,17 +1,10 @@
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils' import * as secp256k1 from '@noble/secp256k1'
import {bech32} from '@scure/base' import {bech32} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts' import {utf8Decoder, utf8Encoder} from './utils'
const Bech32MaxSize = 5000 const Bech32MaxSize = 5000
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX =
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
export type ProfilePointer = { export type ProfilePointer = {
pubkey: string // hex pubkey: string // hex
relays?: string[] relays?: string[]
@@ -30,16 +23,10 @@ export type AddressPointer = {
relays?: string[] relays?: string[]
} }
export type DecodeResult = export function decode(nip19: string): {
| {type: 'nprofile'; data: ProfilePointer} type: string
| {type: 'nrelay'; data: string} data: ProfilePointer | EventPointer | AddressPointer | string
| {type: 'nevent'; data: EventPointer} } {
| {type: 'naddr'; data: AddressPointer}
| {type: 'nsec'; data: string}
| {type: 'npub'; data: string}
| {type: 'note'; data: string}
export function decode(nip19: string): DecodeResult {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize) let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words)) let data = new Uint8Array(bech32.fromWords(words))
@@ -52,7 +39,7 @@ export function decode(nip19: string): DecodeResult {
return { return {
type: 'nprofile', type: 'nprofile',
data: { data: {
pubkey: bytesToHex(tlv[0][0]), pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [] relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
} }
} }
@@ -67,9 +54,11 @@ export function decode(nip19: string): DecodeResult {
return { return {
type: 'nevent', type: 'nevent',
data: { data: {
id: bytesToHex(tlv[0][0]), id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [], relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined author: tlv[2]?.[0]
? secp256k1.utils.bytesToHex(tlv[2][0])
: undefined
} }
} }
} }
@@ -86,27 +75,17 @@ export function decode(nip19: string): DecodeResult {
type: 'naddr', type: 'naddr',
data: { data: {
identifier: utf8Decoder.decode(tlv[0][0]), identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]), pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
kind: parseInt(bytesToHex(tlv[3][0]), 16), kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [] relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
} }
} }
} }
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':
case 'npub': case 'npub':
case 'note': case 'note':
return {type: prefix, data: bytesToHex(data)} return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
default: default:
throw new Error(`unknown prefix ${prefix}`) throw new Error(`unknown prefix ${prefix}`)
@@ -121,10 +100,9 @@ function parseTLV(data: Uint8Array): TLV {
while (rest.length > 0) { while (rest.length > 0) {
let t = rest[0] let t = rest[0]
let l = rest[1] let l = rest[1]
if (!l) throw new Error(`malformed TLV ${t}`)
let v = rest.slice(2, 2 + l) let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l) rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`) if (v.length < l) continue
result[t] = result[t] || [] result[t] = result[t] || []
result[t].push(v) result[t].push(v)
} }
@@ -144,14 +122,14 @@ export function noteEncode(hex: string): string {
} }
function encodeBytes(prefix: string, hex: string): string { function encodeBytes(prefix: string, hex: string): string {
let data = hexToBytes(hex) let data = secp256k1.utils.hexToBytes(hex)
let words = bech32.toWords(data) let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) return bech32.encode(prefix, words, Bech32MaxSize)
} }
export function nprofileEncode(profile: ProfilePointer): string { export function nprofileEncode(profile: ProfilePointer): string {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(profile.pubkey)], 0: [secp256k1.utils.hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)) 1: (profile.relays || []).map(url => utf8Encoder.encode(url))
}) })
let words = bech32.toWords(data) let words = bech32.toWords(data)
@@ -160,9 +138,9 @@ export function nprofileEncode(profile: ProfilePointer): string {
export function neventEncode(event: EventPointer): string { export function neventEncode(event: EventPointer): string {
let data = encodeTLV({ let data = encodeTLV({
0: [hexToBytes(event.id)], 0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)), 1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : [] 2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
}) })
let words = bech32.toWords(data) let words = bech32.toWords(data)
return bech32.encode('nevent', words, Bech32MaxSize) return bech32.encode('nevent', words, Bech32MaxSize)
@@ -175,21 +153,13 @@ export function naddrEncode(addr: AddressPointer): string {
let data = encodeTLV({ let data = encodeTLV({
0: [utf8Encoder.encode(addr.identifier)], 0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)), 1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [hexToBytes(addr.pubkey)], 2: [secp256k1.utils.hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)] 3: [new Uint8Array(kind)]
}) })
let words = bech32.toWords(data) let words = bech32.toWords(data)
return bech32.encode('naddr', words, Bech32MaxSize) return bech32.encode('naddr', words, Bech32MaxSize)
} }
export function nrelayEncode(url: string): string {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
let words = bech32.toWords(data)
return bech32.encode('nrelay', words, Bech32MaxSize)
}
function encodeTLV(tlv: TLV): Uint8Array { function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = [] let entries: Uint8Array[] = []
@@ -203,5 +173,5 @@ function encodeTLV(tlv: TLV): Uint8Array {
}) })
}) })
return concatBytes(...entries) return secp256k1.utils.concatBytes(...entries)
} }

View File

@@ -1,41 +0,0 @@
import {test as testRegex, parse} from './nip21.ts'
test('test()', () => {
expect(
testRegex(
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(true)
expect(
testRegex(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
testRegex(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('nostr:')).toBe(false)
expect(
testRegex(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('gggggg')).toBe(false)
})
test('parse', () => {
const result = parse(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
}
})
})

View File

@@ -1,33 +0,0 @@
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts'
/** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` {
return (
typeof value === 'string' &&
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
)
}
/** Parsed Nostr URI data. */
export interface NostrURI {
/** Full URI including the `nostr:` protocol. */
uri: `nostr:${string}`
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded: DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: decode(match[1])
}
}

View File

@@ -1,78 +0,0 @@
import {finishEvent, Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {finishReactionEvent, getReactedEventPointer} from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const reactedEvent = finishEvent(
{
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey']
],
content: 'Replied to a post',
created_at: 1617932115
},
privateKey
)
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
])
expect(event.content).toEqual('+')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
created_at: 1617932115
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
])
expect(event.content).toEqual('👍')
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
})

View File

@@ -1,69 +0,0 @@
import {Event, finishEvent, Kind} from './event.ts'
import type {EventPointer} from './nip19.ts'
export type ReactionEventTemplate = {
/**
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
*/
tags?: string[][]
/**
* @default '+'
*/
content?: string
created_at: number
}
export function finishReactionEvent(
t: ReactionEventTemplate,
reacted: Event<number>,
privateKey: string,
): Event<Kind.Reaction> {
const inheritedTags = reacted.tags.filter(
(tag) => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'),
)
return finishEvent({
...t,
kind: Kind.Reaction,
tags: [
...(t.tags ?? []),
...inheritedTags,
['e', reacted.id],
['p', reacted.pubkey],
],
content: t.content ?? '+',
}, privateKey)
}
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Reaction) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined || lastPTag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined),
author: lastPTag[1],
}
}

View File

@@ -1,10 +1,10 @@
import {getPublicKey, generatePrivateKey} from './keys.ts' /* eslint-env jest */
import {getDelegator, createDelegation} from './nip26.ts'
import {buildEvent} from './test-helpers.ts' const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('parse good delegation from NIP', async () => { test('parse good delegation from NIP', async () => {
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
@@ -26,7 +26,7 @@ test('parse good delegation from NIP', async () => {
test('parse bad delegations', async () => { test('parse bad delegations', async () => {
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
@@ -46,7 +46,7 @@ test('parse bad delegations', async () => {
).toEqual(null) ).toEqual(null)
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
@@ -66,7 +66,7 @@ test('parse bad delegations', async () => {
).toEqual(null) ).toEqual(null)
expect( expect(
getDelegator({ nip26.getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey: pubkey:
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49', '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
@@ -91,15 +91,15 @@ test('create and verify delegation', async () => {
let pk1 = getPublicKey(sk1) let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey() let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2) let pk2 = getPublicKey(sk2)
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1}) let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1})
expect(delegation).toHaveProperty('from', pk1) expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2) expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1') expect(delegation).toHaveProperty('cond', 'kind=1')
let event = buildEvent({ let event = {
kind: 1, kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]], tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2, pubkey: pk2
}) }
expect(getDelegator(event)).toEqual(pk1) expect(nip26.getDelegator(event)).toEqual(pk1)
}) })

View File

@@ -1,17 +1,15 @@
import {schnorr} from '@noble/curves/secp256k1' import * as secp256k1 from '@noble/secp256k1'
import {bytesToHex} from '@noble/hashes/utils'
import {sha256} from '@noble/hashes/sha256' import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils.ts' import {Event} from './event'
import {getPublicKey} from './keys.ts' import {utf8Encoder} from './utils'
import {getPublicKey} from './keys'
import type {Event} from './event.ts'
export type Parameters = { export type Parameters = {
pubkey: string // the key to whom the delegation will be given pubkey: string // the key to whom the delegation will be given
kind?: number kind: number | undefined
until?: number // delegation will only be valid until this date until: number | undefined // delegation will only be valid until this date
since?: number // delegation will be valid from this date on since: number | undefined // delegation will be valid from this date on
} }
export type Delegation = { export type Delegation = {
@@ -38,8 +36,8 @@ export function createDelegation(
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`) utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
) )
let sig = bytesToHex( let sig = secp256k1.utils.bytesToHex(
schnorr.sign(sighash, privateKey) secp256k1.schnorr.signSync(sighash, privateKey)
) )
return { return {
@@ -50,7 +48,7 @@ export function createDelegation(
} }
} }
export function getDelegator(event: Event<number>): string | null { export function getDelegator(event: Event): string | null {
// find delegation tag // find delegation tag
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4) let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
if (!tag) return null if (!tag) return null
@@ -86,7 +84,7 @@ export function getDelegator(event: Event<number>): string | null {
let sighash = sha256( let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`) utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
) )
if (!schnorr.verify(sig, sighash, pubkey)) return null if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null
return pubkey return pubkey
} }

View File

@@ -1,67 +0,0 @@
import {matchAll, replaceAll} from './nip27.ts'
test('matchAll', () => {
const result = matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
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', () => {
const result = matchAll(
'Hello npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
decoded: {
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
type: 'note'
},
end: 187,
start: 118,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
}
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = replaceAll(content, ({decoded, value}) => {
switch (decoded.type) {
case 'npub':
return '@alex'
case 'note':
return '!1234'
default:
return value
}
})
expect(result).toEqual('Hello @alex!\n\n!1234')
})

View File

@@ -1,66 +0,0 @@
import {decode} from './nip19.ts'
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends NostrURI {
/** 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* matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
try {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
start: match.index!,
end: match.index! + uri.length
}
} catch (_e) {
// do nothing
}
}
}
/**
* Replace all occurrences of Nostr URIs in the text.
*
* 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) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value)
})
})
}

View File

@@ -1,11 +1,12 @@
import fetch from 'node-fetch' /* eslint-env jest */
import {useFetchImplementation, validateGithub} from './nip39.ts' const fetch = require('node-fetch')
const {nip39} = require('./lib/nostr.cjs.js')
test('validate github claim', async () => { test('validate github claim', async () => {
useFetchImplementation(fetch) nip39.useFetchImplementation(fetch)
let result = await validateGithub( let result = await nip39.validateGithub(
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z', 'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'vitorpamplona', 'vitorpamplona',
'cf19e2d1d7f8dac6348ad37b35ec8421' 'cf19e2d1d7f8dac6348ad37b35ec8421'

154
nip41.test.js Normal file
View File

@@ -0,0 +1,154 @@
/* eslint-env jest */
const secp256k1 = require('@noble/secp256k1')
const {
getPublicKey,
validateEvent,
verifySignature,
generatePrivateKey,
nip41
} = require('./lib/nostr.cjs')
test('sanity', () => {
let sk = generatePrivateKey()
expect(getPublicKey(sk)).toEqual(secp256k1.Point.fromPrivateKey(sk).toHexX())
})
test('key arithmetics', () => {
expect(
secp256k1.utils.mod(secp256k1.CURVE.n + 1n, secp256k1.CURVE.n)
).toEqual(1n)
let veryHighPoint = secp256k1.Point.fromPrivateKey(
(secp256k1.CURVE.n - 1n).toString(16).padStart(64, '0')
)
let pointAt2 = secp256k1.Point.fromPrivateKey(
2n.toString(16).padStart(64, '0')
)
let pointAt1 = secp256k1.Point.fromPrivateKey(
1n.toString(16).padStart(64, '0')
)
expect(veryHighPoint.add(pointAt2)).toEqual(pointAt1)
expect(
secp256k1.getPublicKey(1n.toString(16).padStart(64, '0'), true)
).toEqual(pointAt1.toRawBytes(true))
})
test('testing getting child keys compatibility', () => {
let sk = '2222222222222222222222222222222222222222222222222222222222222222'
let pk = secp256k1.getPublicKey(sk, true)
let hsk = '3333333333333333333333333333333333333333333333333333333333333333'
let hpk = secp256k1.getPublicKey(hsk, true)
expect(secp256k1.utils.bytesToHex(nip41.getChildPublicKey(pk, hpk))).toEqual(
secp256k1.utils.bytesToHex(
secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true)
)
)
})
test('more testing child key derivation', () => {
;[
{
sk: '448aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '00ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '02e3990b0eb40452a8ffbd9fe99037deb7beeb6ab26020e8c0e8284f3009a56d0c',
hpk: '029e9cb07f3a3b8abcad629920d4a5460aefb6b7c08704b7f1ced8648b007ef65f'
},
{
sk: '778aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '99ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '020d09894e321f53a7ac8bc003cb1563a4857d57ea69c39ab7189e2cccedc17d1b',
hpk: '0358fe19e14c78c4a8c0037a2b9d3e3a714717f2a2d8dd54a5e88d283440dcb28a'
},
{
sk: '2eb5edc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26',
hsk: '65d515a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58',
pk: '03dd651a07dc6c9a54b596f6492c9623a595cb48e31af04f8c322d4ce81accb2b0',
hpk: '03b8c98d920141a1e168d21e9315cf933a601872ebf57751b30797fb98526c2f4f'
}
].forEach(({pk, hpk, sk, hsk}) => {
expect(
secp256k1.utils.bytesToHex(secp256k1.getPublicKey(sk, true))
).toEqual(pk)
expect(
secp256k1.utils.bytesToHex(secp256k1.getPublicKey(hsk, true))
).toEqual(hpk)
expect(
secp256k1.utils.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes(pk),
secp256k1.utils.hexToBytes(hpk)
)
)
).toEqual(
secp256k1.utils.bytesToHex(
secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true)
)
)
})
})
test('generating a revocation event and validating it', () => {
const mnemonic =
'air property excess weird rare rival fade intact brave office mirror wait'
const firstKey = nip41.getPrivateKeyAtIndex(mnemonic, 9)
// expect(firstKey).toEqual(
// '8495ba55f56485d378aa275604a45e76abbcae177e374fa06af5770c3b8e24af'
// )
const firstPubkey = getPublicKey(firstKey)
// expect(firstPubkey).toEqual(
// '35246813a0dd45e74ce22ecdf052cca8ed47759c8f8d412c281dc2755110956f'
// )
// first key is compromised, revoke it
let {parentPrivateKey, event} = nip41.buildRevocationEvent(
mnemonic,
firstPubkey
)
const secondKey = nip41.getPrivateKeyAtIndex(mnemonic, 8)
expect(parentPrivateKey).toEqual(secondKey)
expect(secondKey).toEqual(
'1b311655ef73bed3bbebc83d0cb3eef42c6aff45f944e3a0c263eb6fdf98c617'
)
expect(event).toHaveProperty('kind', 13)
expect(event.tags).toHaveLength(2)
expect(event.tags[0]).toHaveLength(2)
expect(event.tags[1]).toHaveLength(2)
expect(event.tags[0][0]).toEqual('p')
expect(event.tags[1][0]).toEqual('hidden-key')
let hiddenKey = secp256k1.utils.hexToBytes(event.tags[1][1])
let pubkeyAlt1 = secp256k1.utils
.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes('02' + event.pubkey),
hiddenKey
)
)
.slice(2)
let pubkeyAlt2 = secp256k1.utils
.bytesToHex(
nip41.getChildPublicKey(
secp256k1.utils.hexToBytes('03' + event.pubkey),
hiddenKey
)
)
.slice(2)
expect([pubkeyAlt1, pubkeyAlt2]).toContain(event.tags[0][1])
// receiver of revocation event can validate it
let secondPubkey = getPublicKey(secondKey)
expect(event.pubkey).toEqual(secondPubkey)
expect(validateEvent(event)).toBeTruthy()
expect(verifySignature(event)).toBeTruthy()
expect(nip41.validateRevocation(event)).toBeTruthy()
})

160
nip41.ts Normal file
View File

@@ -0,0 +1,160 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {mnemonicToSeedSync} from '@scure/bip39'
import {HARDENED_OFFSET, HDKey} from '@scure/bip32'
import {getPublicKey} from './keys'
import {Event, getEventHash, Kind, signEvent, verifySignature} from './event'
const MaxKeys = 256
function getRootFromMnemonic(mnemonic: string): HDKey {
return HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive(
`m/44'/1237'/41'`
)
}
export function getPrivateKeyAtIndex(
mnemonic: string,
targetIdx: number
): string {
let root = getRootFromMnemonic(mnemonic)
let rootPrivateKey = secp256k1.utils.bytesToHex(root.privateKey as Uint8Array)
let currentPrivateKey = rootPrivateKey
for (let idx = 1; idx <= targetIdx; idx++) {
let hiddenPrivateKey = secp256k1.utils.bytesToHex(
root.deriveChild(idx + HARDENED_OFFSET).privateKey as Uint8Array
)
currentPrivateKey = getChildPrivateKey(currentPrivateKey, hiddenPrivateKey)
}
return currentPrivateKey
}
export function getPublicKeyAtIndex(
root: HDKey,
targetIdx: number
): Uint8Array {
let rootPublicKey = root.publicKey as Uint8Array
let currentPublicKey = rootPublicKey
for (let idx = 1; idx <= targetIdx; idx++) {
let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET)
.publicKey as Uint8Array
currentPublicKey = getChildPublicKey(currentPublicKey, hiddenPublicKey)
}
return currentPublicKey
}
function getIndexOfPublicKey(root: HDKey, publicKey: string): number {
let rootPublicKey = root.publicKey as Uint8Array
if (secp256k1.utils.bytesToHex(rootPublicKey).slice(2) === publicKey) return 0
let currentPublicKey = rootPublicKey
for (let idx = 1; idx <= MaxKeys; idx++) {
let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET)
.publicKey as Uint8Array
let pubkeyAtIndex = getChildPublicKey(currentPublicKey, hiddenPublicKey)
if (secp256k1.utils.bytesToHex(pubkeyAtIndex).slice(2) === publicKey)
return idx
currentPublicKey = pubkeyAtIndex
}
throw new Error(
`public key ${publicKey} not in the set of the first ${MaxKeys} public keys`
)
}
export function getChildPublicKey(
parentPublicKey: Uint8Array,
hiddenPublicKey: Uint8Array
): Uint8Array {
if (parentPublicKey.length !== 33 || hiddenPublicKey.length !== 33)
throw new Error(
'getChildPublicKey() requires public keys with the leading differentiator byte.'
)
let hash = sha256(
secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey)
)
let hashPoint = secp256k1.Point.fromPrivateKey(hash)
let point = secp256k1.Point.fromHex(hiddenPublicKey).add(hashPoint)
return point.toRawBytes(true)
}
export function getChildPrivateKey(
parentPrivateKey: string,
hiddenPrivateKey: string
): string {
let parentPublicKey = secp256k1.getPublicKey(parentPrivateKey, true)
let hiddenPublicKey = secp256k1.getPublicKey(hiddenPrivateKey, true)
let hash = sha256(
secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey)
)
let hashScalar = BigInt(`0x${secp256k1.utils.bytesToHex(hash)}`)
let hiddenPrivateKeyScalar = BigInt(`0x${hiddenPrivateKey}`)
let sumScalar = hiddenPrivateKeyScalar + hashScalar
let modulo = secp256k1.utils.mod(sumScalar, secp256k1.CURVE.n)
return modulo.toString(16).padStart(64, '0')
}
export function buildRevocationEvent(
mnemonic: string,
compromisedKey: string,
content = ''
): {
parentPrivateKey: string
event: Event
} {
let root = getRootFromMnemonic(mnemonic)
let idx = getIndexOfPublicKey(root, compromisedKey)
let hiddenKey = secp256k1.utils.bytesToHex(
root.deriveChild(idx + HARDENED_OFFSET).publicKey as Uint8Array
)
let parentPrivateKey = getPrivateKeyAtIndex(mnemonic, idx - 1)
let parentPublicKey = getPublicKey(parentPrivateKey)
let event: Event = {
kind: 13,
tags: [
['p', compromisedKey],
['hidden-key', hiddenKey]
],
created_at: Math.round(Date.now() / 1000),
content,
pubkey: parentPublicKey
}
event.sig = signEvent(event, parentPrivateKey)
event.id = getEventHash(event)
return {parentPrivateKey, event}
}
export function validateRevocation(event: Event): boolean {
if (event.kind !== Kind.StatelessRevocation) return false
if (!verifySignature(event)) return false
let invalidKeyTag = event.tags.find(([t, v]) => t === 'p' && v)
if (!invalidKeyTag) return false
let invalidKey = invalidKeyTag[1]
let hiddenKeyTag = event.tags.find(([t, v]) => t === 'hidden-key' && v)
if (!hiddenKeyTag) return false
let hiddenKey = secp256k1.utils.hexToBytes(hiddenKeyTag[1])
if (hiddenKey.length !== 33) return false
let currentKeyAlt1 = secp256k1.utils.hexToBytes('02' + event.pubkey)
let currentKeyAlt2 = secp256k1.utils.hexToBytes('03' + event.pubkey)
let childKeyAlt1 = secp256k1.utils
.bytesToHex(getChildPublicKey(currentKeyAlt1, hiddenKey))
.slice(2)
let childKeyAlt2 = secp256k1.utils
.bytesToHex(getChildPublicKey(currentKeyAlt2, hiddenKey))
.slice(2)
return childKeyAlt1 === invalidKey || childKeyAlt2 === invalidKey
}

View File

@@ -1,26 +0,0 @@
import 'websocket-polyfill'
import {finishEvent} from './event.ts'
import {generatePrivateKey} from './keys.ts'
import {authenticate} from './nip42.ts'
import {relayInit} from './relay.ts'
test('auth flow', () => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
return new Promise<void>((resolve) => {
relay.on('auth', async challenge => {
await expect(
authenticate({
challenge,
relay,
sign: (e) => finishEvent(e, sk)
})
).rejects.toBeTruthy()
relay.close()
resolve()
})
})
})

View File

@@ -1,42 +0,0 @@
import {Kind, type EventTemplate, type Event} from './event.ts'
import {Relay} from './relay.ts'
/**
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
*/
export const authenticate = async ({
challenge,
relay,
sign
}: {
challenge: string
relay: Relay
sign: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: ''
}
const pub = relay.auth(await sign(e))
return new Promise((resolve, reject) => {
pub.on('ok', function ok() {
pub.off('ok', ok)
resolve()
})
pub.on('failed', function fail(reason: string) {
pub.off('failed', fail)
reject(reason)
})
})
}

View File

@@ -1,28 +1,25 @@
import {finishEvent} from './event.ts' const {bech32} = require('@scure/base')
import {getPublicKey, generatePrivateKey} from './keys.ts' const {
import { nip57,
getZapEndpoint, generatePrivateKey,
makeZapReceipt, getPublicKey,
makeZapRequest, finishEvent
useFetchImplementation, } = require('./lib/nostr.cjs')
validateZapRequest,
} from './nip57.ts'
import {buildEvent} from './test-helpers.ts'
describe('getZapEndpoint', () => { describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => { test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({kind: 0, content: '{}'}) const metadata = {content: '{}'}
const result = await getZapEndpoint(metadata) const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull() expect(result).toBeNull()
}) })
test('returns null if fetch fails', async () => { test('returns null if fetch fails', async () => {
const fetchImplementation = jest.fn(() => Promise.reject(new Error())) const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation) nip57.useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'}) const metadata = {content: '{"lud16": "name@domain"}'}
const result = await getZapEndpoint(metadata) const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull() expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith( expect(fetchImplementation).toHaveBeenCalledWith(
@@ -34,10 +31,10 @@ describe('getZapEndpoint', () => {
const fetchImplementation = jest.fn(() => const fetchImplementation = jest.fn(() =>
Promise.resolve({json: () => ({allowsNostr: false})}) Promise.resolve({json: () => ({allowsNostr: false})})
) )
useFetchImplementation(fetchImplementation) nip57.useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'}) const metadata = {content: '{"lud16": "name@domain"}'}
const result = await getZapEndpoint(metadata) const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull() expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith( expect(fetchImplementation).toHaveBeenCalledWith(
@@ -55,10 +52,10 @@ describe('getZapEndpoint', () => {
}) })
}) })
) )
useFetchImplementation(fetchImplementation) nip57.useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'}) const metadata = {content: '{"lud16": "name@domain"}'}
const result = await getZapEndpoint(metadata) const result = await nip57.getZapEndpoint(metadata)
expect(result).toBe('callback') expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith( expect(fetchImplementation).toHaveBeenCalledWith(
@@ -70,8 +67,7 @@ describe('getZapEndpoint', () => {
describe('makeZapRequest', () => { describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => { test('throws an error if amount is not given', () => {
expect(() => expect(() =>
// @ts-expect-error nip57.makeZapRequest({
makeZapRequest({
profile: 'profile', profile: 'profile',
event: null, event: null,
relays: [], relays: [],
@@ -82,8 +78,7 @@ describe('makeZapRequest', () => {
test('throws an error if profile is not given', () => { test('throws an error if profile is not given', () => {
expect(() => expect(() =>
// @ts-expect-error nip57.makeZapRequest({
makeZapRequest({
event: null, event: null,
amount: 100, amount: 100,
relays: [], relays: [],
@@ -93,7 +88,7 @@ describe('makeZapRequest', () => {
}) })
test('returns a valid Zap request', () => { test('returns a valid Zap request', () => {
const result = makeZapRequest({ const result = nip57.makeZapRequest({
profile: 'profile', profile: 'profile',
event: 'event', event: 'event',
amount: 100, amount: 100,
@@ -116,7 +111,7 @@ describe('makeZapRequest', () => {
describe('validateZapRequest', () => { describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => { test('returns an error message for invalid JSON', () => {
expect(validateZapRequest('invalid JSON')).toBe( expect(nip57.validateZapRequest('invalid JSON')).toBe(
'Invalid zap request JSON.' 'Invalid zap request JSON.'
) )
}) })
@@ -133,7 +128,7 @@ describe('validateZapRequest', () => {
] ]
} }
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Zap request is not a valid Nostr event.' 'Zap request is not a valid Nostr event.'
) )
}) })
@@ -154,7 +149,7 @@ describe('validateZapRequest', () => {
] ]
} }
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Invalid signature on zap request.' 'Invalid signature on zap request.'
) )
}) })
@@ -175,7 +170,7 @@ describe('validateZapRequest', () => {
privateKey privateKey
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'p' tag." "Zap request doesn't have a 'p' tag."
) )
}) })
@@ -197,7 +192,7 @@ describe('validateZapRequest', () => {
privateKey privateKey
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'p' tag is not valid hex." "Zap request 'p' tag is not valid hex."
) )
}) })
@@ -221,7 +216,7 @@ describe('validateZapRequest', () => {
privateKey privateKey
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'e' tag is not valid hex." "Zap request 'e' tag is not valid hex."
) )
}) })
@@ -243,7 +238,7 @@ describe('validateZapRequest', () => {
privateKey privateKey
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBe( expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'relays' tag." "Zap request doesn't have a 'relays' tag."
) )
}) })
@@ -266,7 +261,7 @@ describe('validateZapRequest', () => {
privateKey privateKey
) )
expect(validateZapRequest(JSON.stringify(zapRequest))).toBeNull() expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
}) })
}) })
@@ -294,7 +289,7 @@ describe('makeZapReceipt', () => {
const bolt11 = 'bolt11' const bolt11 = 'bolt11'
const paidAt = new Date() const paidAt = new Date()
const result = makeZapReceipt({zapRequest, preimage, bolt11, paidAt}) const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
expect(result.kind).toBe(9735) expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0) expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
@@ -327,7 +322,7 @@ describe('makeZapReceipt', () => {
const bolt11 = 'bolt11' const bolt11 = 'bolt11'
const paidAt = new Date() const paidAt = new Date()
const result = makeZapReceipt({zapRequest, bolt11, paidAt}) const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
expect(result.kind).toBe(9735) expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0) expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)

View File

@@ -1,13 +1,7 @@
import {bech32} from '@scure/base' import {bech32} from '@scure/base'
import { import {Event, EventTemplate, validateEvent, verifySignature} from './event'
Kind, import {utf8Decoder} from './utils'
validateEvent,
verifySignature,
type Event,
type EventTemplate,
} from './event.ts'
import {utf8Decoder} from './utils.ts'
var _fetch: any var _fetch: any
@@ -19,9 +13,7 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation _fetch = fetchImplementation
} }
export async function getZapEndpoint( export async function getZapEndpoint(metadata: Event): Promise<null | string> {
metadata: Event<Kind.Metadata>
): Promise<null | string> {
try { try {
let lnurl: string = '' let lnurl: string = ''
let {lud06, lud16} = JSON.parse(metadata.content) let {lud06, lud16} = JSON.parse(metadata.content)
@@ -61,11 +53,11 @@ export function makeZapRequest({
amount: number amount: number
comment: string comment: string
relays: string[] relays: string[]
}): EventTemplate<Kind.ZapRequest> { }): EventTemplate {
if (!amount) throw new Error('amount not given') if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given') if (!profile) throw new Error('profile not given')
let zr: EventTemplate<Kind.ZapRequest> = { let zr = {
kind: 9734, kind: 9734,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: comment, content: comment,
@@ -94,7 +86,6 @@ export function validateZapRequest(zapRequestString: string): string | null {
if (!validateEvent(zapRequest)) if (!validateEvent(zapRequest))
return 'Zap request is not a valid Nostr event.' return 'Zap request is not a valid Nostr event.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.' if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v) let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
@@ -119,16 +110,16 @@ export function makeZapReceipt({
paidAt paidAt
}: { }: {
zapRequest: string zapRequest: string
preimage?: string preimage: string | null
bolt11: string bolt11: string
paidAt: Date paidAt: Date
}): EventTemplate<Kind.Zap> { }): EventTemplate {
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest) let zr: Event = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter( let tagsFromZapRequest = zr.tags.filter(
([t]) => t === 'e' || t === 'p' || t === 'a' ([t]) => t === 'e' || t === 'p' || t === 'a'
) )
let zap: EventTemplate<Kind.Zap> = { let zap = {
kind: 9735, kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000), created_at: Math.round(paidAt.getTime() / 1000),
content: '', content: '',

View File

@@ -1,6 +1,6 @@
{ {
"name": "nostr-tools", "name": "nostr-tools",
"version": "1.12.1", "version": "1.8.2",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -14,16 +14,16 @@
"module": "lib/esm/nostr.mjs", "module": "lib/esm/nostr.mjs",
"exports": { "exports": {
"import": "./lib/esm/nostr.mjs", "import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js", "require": "./lib/nostr.cjs.js"
"types": "./lib/index.d.ts"
}, },
"license": "Unlicense", "license": "Public domain",
"dependencies": { "dependencies": {
"@noble/curves": "1.0.0", "@noble/hashes": "1.0.0",
"@noble/hashes": "1.3.0", "@noble/secp256k1": "^1.7.1",
"@scure/base": "1.1.1", "@scure/base": "^1.1.1",
"@scure/bip32": "1.3.0", "@scure/bip32": "^1.1.5",
"@scure/bip39": "1.2.0" "@scure/bip39": "^1.1.1",
"prettier": "^2.8.4"
}, },
"keywords": [ "keywords": [
"decentralization", "decentralization",
@@ -32,30 +32,21 @@
"client", "client",
"nostr" "nostr"
], ],
"scripts": {
"build": "node build",
"format": "prettier --plugin-search-dir . --write .",
"test": "jest"
},
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^18.13.0", "@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0", "@typescript-eslint/parser": "^5.51.0",
"esbuild": "0.16.9", "esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1", "esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.40.0", "eslint": "^8.33.0",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.1",
"esm-loader-typescript": "^1.0.3", "esm-loader-typescript": "^1.0.3",
"events": "^3.3.0", "events": "^3.3.0",
"jest": "^29.5.0", "jest": "^29.4.2",
"node-fetch": "^2.6.9", "node-fetch": "^2.6.9",
"prettier": "^2.8.4", "ts-jest": "^29.0.5",
"ts-jest": "^29.1.0",
"tsd": "^0.22.0", "tsd": "^0.22.0",
"typescript": "^5.0.4", "typescript": "^4.9.5",
"websocket-polyfill": "^0.0.3" "websocket-polyfill": "^0.0.3"
} }
} }

View File

@@ -1,8 +1,13 @@
import 'websocket-polyfill' /* eslint-env jest */
import {finishEvent, type Event} from './event.ts' require('websocket-polyfill')
import {generatePrivateKey, getPublicKey} from './keys.ts' const {
import {SimplePool} from './pool.ts' SimplePool,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
let pool = new SimplePool() let pool = new SimplePool()
@@ -28,7 +33,7 @@ test('removing duplicates when querying', async () => {
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{authors: [pub]}]) let sub = pool.sub(relays, [{authors: [pub]}])
let received: Event[] = [] let received = []
sub.on('event', event => { sub.on('event', event => {
// this should be called only once even though we're listening // this should be called only once even though we're listening
@@ -37,12 +42,15 @@ test('removing duplicates when querying', async () => {
received.push(event) received.push(event)
}) })
let event = finishEvent({ let event = {
pubkey: pub,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: 'test', content: 'test',
kind: 22345, kind: 22345,
tags: [] tags: []
}, priv) }
event.id = getEventHash(event)
event.sig = signEvent(event, priv)
pool.publish(relays, event) pool.publish(relays, event)
@@ -58,7 +66,7 @@ test('same with double querying', async () => {
let sub1 = pool.sub(relays, [{authors: [pub]}]) let sub1 = pool.sub(relays, [{authors: [pub]}])
let sub2 = pool.sub(relays, [{authors: [pub]}]) let sub2 = pool.sub(relays, [{authors: [pub]}])
let received: Event[] = [] let received = []
sub1.on('event', event => { sub1.on('event', event => {
received.push(event) received.push(event)
@@ -68,12 +76,15 @@ test('same with double querying', async () => {
received.push(event) received.push(event)
}) })
let event = finishEvent({ let event = {
pubkey: pub,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: 'test2', content: 'test2',
kind: 22346, kind: 22346,
tags: [] tags: []
}, priv) }
event.id = getEventHash(event)
event.sig = signEvent(event, priv)
pool.publish(relays, event) pool.publish(relays, event)
@@ -111,7 +122,6 @@ test('list()', async () => {
expect(events.length).toEqual( expect(events.length).toEqual(
events events
.map(evt => evt.id) .map(evt => evt.id)
// @ts-ignore ???
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), []) .reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
.length .length
) )

41
pool.ts
View File

@@ -1,14 +1,9 @@
import { import {Relay, relayInit} from './relay'
relayInit, import {normalizeURL} from './utils'
type Pub, import {Filter} from './filter'
type Relay, import {Event} from './event'
type Sub, import {SubscriptionOptions, Sub, Pub} from './relay'
type SubscriptionOptions,
} from './relay.ts'
import {normalizeURL} from './utils.ts'
import type {Event} from './event.ts'
import type {Filter} from './filter.ts'
export class SimplePool { export class SimplePool {
private _conn: {[url: string]: Relay} private _conn: {[url: string]: Relay}
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
@@ -44,7 +39,7 @@ export class SimplePool {
return relay return relay
} }
sub<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> { sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
let _knownIds: Set<string> = new Set() let _knownIds: Set<string> = new Set()
let modifiedOpts = {...(opts || {})} let modifiedOpts = {...(opts || {})}
modifiedOpts.alreadyHaveEvent = (id, url) => { modifiedOpts.alreadyHaveEvent = (id, url) => {
@@ -58,7 +53,7 @@ export class SimplePool {
} }
let subs: Sub[] = [] let subs: Sub[] = []
let eventListeners: Set<any> = new Set() let eventListeners: Set<(event: Event) => void> = new Set()
let eoseListeners: Set<() => void> = new Set() let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length let eosesMissing = relays.length
@@ -78,7 +73,7 @@ export class SimplePool {
} }
if (!r) return if (!r) return
let s = r.sub(filters, modifiedOpts) let s = r.sub(filters, modifiedOpts)
s.on('event', (event) => { s.on('event', (event: Event) => {
_knownIds.add(event.id as string) _knownIds.add(event.id as string)
for (let cb of eventListeners.values()) cb(event) for (let cb of eventListeners.values()) cb(event)
}) })
@@ -123,18 +118,18 @@ export class SimplePool {
return greaterSub return greaterSub
} }
get<K extends number = number>( get(
relays: string[], relays: string[],
filter: Filter<K>, filter: Filter,
opts?: SubscriptionOptions opts?: SubscriptionOptions
): Promise<Event<K> | null> { ): Promise<Event | null> {
return new Promise(resolve => { return new Promise(resolve => {
let sub = this.sub(relays, [filter], opts) let sub = this.sub(relays, [filter], opts)
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
sub.unsub() sub.unsub()
resolve(null) resolve(null)
}, this.getTimeout) }, this.getTimeout)
sub.on('event', (event) => { sub.on('event', (event: Event) => {
resolve(event) resolve(event)
clearTimeout(timeout) clearTimeout(timeout)
sub.unsub() sub.unsub()
@@ -142,16 +137,16 @@ export class SimplePool {
}) })
} }
list<K extends number = number>( list(
relays: string[], relays: string[],
filters: Filter<K>[], filters: Filter[],
opts?: SubscriptionOptions opts?: SubscriptionOptions
): Promise<Event<K>[]> { ): Promise<Event[]> {
return new Promise(resolve => { return new Promise(resolve => {
let events: Event<K>[] = [] let events: Event[] = []
let sub = this.sub(relays, filters, opts) let sub = this.sub(relays, filters, opts)
sub.on('event', (event) => { sub.on('event', (event: Event) => {
events.push(event) events.push(event)
}) })
@@ -163,7 +158,7 @@ export class SimplePool {
}) })
} }
publish(relays: string[], event: Event<number>): Pub { publish(relays: string[], event: Event): Pub {
const pubPromises: Promise<Pub>[] = relays.map(async relay => { const pubPromises: Promise<Pub>[] = relays.map(async relay => {
let r let r
try { try {

View File

@@ -1,8 +1,9 @@
import {parseReferences} from './references.ts' /* eslint-env jest */
import {buildEvent} from './test-helpers.ts'
const {parseReferences} = require('./lib/nostr.cjs')
test('parse mentions', () => { test('parse mentions', () => {
let evt = buildEvent({ let evt = {
tags: [ tags: [
[ [
'p', 'p',
@@ -22,8 +23,8 @@ test('parse mentions', () => {
] ]
], ],
content: content:
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]', 'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]'
}) }
expect(parseReferences(evt)).toEqual([ expect(parseReferences(evt)).toEqual([
{ {

View File

@@ -1,11 +1,5 @@
import { import {Event} from './event'
decode, import {decode, AddressPointer, ProfilePointer, EventPointer} from './nip19'
type AddressPointer,
type ProfilePointer,
type EventPointer,
} from './nip19.ts'
import type {Event} from './event.ts'
type Reference = { type Reference = {
text: string text: string
@@ -87,7 +81,7 @@ export function parseReferences(evt: Event): Reference[] {
} }
case 'a': { case 'a': {
try { try {
let [kind, pubkey, identifier] = tag[1].split(':') let [kind, pubkey, identifier] = ref[1].split(':')
references.push({ references.push({
text: ref[0], text: ref[0],
address: { address: {

View File

@@ -1,8 +1,13 @@
import 'websocket-polyfill' /* eslint-env jest */
import {finishEvent} from './event.ts' require('websocket-polyfill')
import {generatePrivateKey, getPublicKey} from './keys.ts' const {
import {relayInit} from './relay.ts' relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
let relay = relayInit('wss://relay.damus.io/') let relay = relayInit('wss://relay.damus.io/')
@@ -28,8 +33,8 @@ test('connectivity', () => {
}) })
test('querying', async () => { test('querying', async () => {
var resolve1: (value: boolean) => void var resolve1
var resolve2: (value: boolean) => void var resolve2
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -48,10 +53,10 @@ test('querying', async () => {
}) })
let [t1, t2] = await Promise.all([ let [t1, t2] = await Promise.all([
new Promise<boolean>(resolve => { new Promise(resolve => {
resolve1 = resolve resolve1 = resolve
}), }),
new Promise<boolean>(resolve => { new Promise(resolve => {
resolve2 = resolve resolve2 = resolve
}) })
]) ])
@@ -88,8 +93,8 @@ test('list()', async () => {
test('listening (twice) and publishing', async () => { test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey() let sk = generatePrivateKey()
let pk = getPublicKey(sk) let pk = getPublicKey(sk)
var resolve1: (value: boolean) => void var resolve1
var resolve2: (value: boolean) => void var resolve2
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -111,12 +116,15 @@ test('listening (twice) and publishing', async () => {
resolve2(true) resolve2(true)
}) })
let event = finishEvent({ let event = {
kind: 27572, kind: 27572,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [], tags: [],
content: 'nostr-tools test suite' content: 'nostr-tools test suite'
}, sk) }
event.id = getEventHash(event)
event.sig = signEvent(event, sk)
relay.publish(event) relay.publish(event)
return expect( return expect(

175
relay.ts
View File

@@ -1,23 +1,17 @@
/* global WebSocket */ /* global WebSocket */
import {verifySignature, validateEvent, type Event} from './event.ts' import {Event, verifySignature, validateEvent} from './event'
import {matchFilters, type Filter} from './filter.ts' import {Filter, matchFilters} from './filter'
import {getHex64, getSubscriptionId} from './fakejson.ts' import {getHex64, getSubscriptionId} from './fakejson'
import { MessageQueue } from './utils.ts'
type RelayEvent = { type RelayEvent = {
connect: () => void | Promise<void> connect: () => void | Promise<void>
disconnect: () => void | Promise<void> disconnect: () => void | Promise<void>
error: () => void | Promise<void> error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void> notice: (msg: string) => void | Promise<void>
auth: (challenge: string) => void | Promise<void>
} }
export type CountPayload = { type SubEvent = {
count: number event: (event: Event) => void | Promise<void>
}
type SubEvent<K extends number> = {
event: (event: Event<K>) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void> eose: () => void | Promise<void>
} }
export type Relay = { export type Relay = {
@@ -25,15 +19,10 @@ export type Relay = {
status: number status: number
connect: () => Promise<void> connect: () => Promise<void>
close: () => void close: () => void
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K> sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]> list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null> get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
count: ( publish: (event: Event) => Pub
filters: Filter[],
opts?: SubscriptionOptions
) => Promise<CountPayload | null>
publish: (event: Event<number>) => Pub
auth: (event: Event<number>) => Pub
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>( off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T, event: T,
listener: U listener: U
@@ -47,14 +36,14 @@ export type Pub = {
on: (type: 'ok' | 'failed', cb: any) => void on: (type: 'ok' | 'failed', cb: any) => void
off: (type: 'ok' | 'failed', cb: any) => void off: (type: 'ok' | 'failed', cb: any) => void
} }
export type Sub<K extends number = number> = { export type Sub = {
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K> sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
unsub: () => void unsub: () => void
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>( on: <T extends keyof SubEvent, U extends SubEvent[T]>(
event: T, event: T,
listener: U listener: U
) => void ) => void
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>( off: <T extends keyof SubEvent, U extends SubEvent[T]>(
event: T, event: T,
listener: U listener: U
) => void ) => void
@@ -62,34 +51,29 @@ export type Sub<K extends number = number> = {
export type SubscriptionOptions = { export type SubscriptionOptions = {
id?: string id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean) alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
} }
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({
connect: [],
disconnect: [],
error: [],
notice: [],
auth: []
})
export function relayInit( export function relayInit(
url: string, url: string,
options: { options: {
getTimeout?: number getTimeout?: number
listTimeout?: number listTimeout?: number
countTimeout?: number
} = {} } = {}
): Relay { ): Relay {
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options let {listTimeout = 3000, getTimeout = 3000} = options
var ws: WebSocket var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {} var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners = newListeners() var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
connect: [],
disconnect: [],
error: [],
notice: []
}
var subListeners: { var subListeners: {
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]} [subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
} = {} } = {}
var pubListeners: { var pubListeners: {
[eventid: string]: { [eventid: string]: {
@@ -123,24 +107,24 @@ export function relayInit(
listeners.disconnect.forEach(cb => cb()) listeners.disconnect.forEach(cb => cb())
} }
let incomingMessageQueue: MessageQueue = new MessageQueue() let incomingMessageQueue: string[] = []
let handleNextInterval: any let handleNextInterval: any
ws.onmessage = e => { ws.onmessage = e => {
incomingMessageQueue.enqueue(e.data) incomingMessageQueue.push(e.data)
if (!handleNextInterval) { if (!handleNextInterval) {
handleNextInterval = setInterval(handleNext, 0) handleNextInterval = setInterval(handleNext, 0)
} }
} }
function handleNext() { function handleNext() {
if (incomingMessageQueue.size === 0) { if (incomingMessageQueue.length === 0) {
clearInterval(handleNextInterval) clearInterval(handleNextInterval)
handleNextInterval = null handleNextInterval = null
return return
} }
var json = incomingMessageQueue.dequeue() var json = incomingMessageQueue.shift()
if (!json) return if (!json) return
let subid = getSubscriptionId(json) let subid = getSubscriptionId(json)
@@ -162,7 +146,7 @@ export function relayInit(
// will naturally be caught by the encompassing try..catch block // will naturally be caught by the encompassing try..catch block
switch (data[0]) { switch (data[0]) {
case 'EVENT': { case 'EVENT':
let id = data[1] let id = data[1]
let event = data[2] let event = data[2]
if ( if (
@@ -175,14 +159,6 @@ export function relayInit(
;(subListeners[id]?.event || []).forEach(cb => cb(event)) ;(subListeners[id]?.event || []).forEach(cb => cb(event))
} }
return return
}
case 'COUNT':
let id = data[1]
let payload = data[2]
if (openSubs[id]) {
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
}
return
case 'EOSE': { case 'EOSE': {
let id = data[1] let id = data[1]
if (id in subListeners) { if (id in subListeners) {
@@ -207,11 +183,6 @@ export function relayInit(
let notice = data[1] let notice = data[1]
listeners.notice.forEach(cb => cb(notice)) listeners.notice.forEach(cb => cb(notice))
return return
case 'AUTH': {
let challenge = data[1]
listeners.auth?.forEach(cb => cb(challenge))
return
}
} }
} catch (err) { } catch (err) {
return return
@@ -246,15 +217,14 @@ export function relayInit(
} }
} }
const sub = <K extends number = number>( const sub = (
filters: Filter<K>[], filters: Filter[],
{ {
verb = 'REQ',
skipVerification = false, skipVerification = false,
alreadyHaveEvent = null, alreadyHaveEvent = null,
id = Math.random().toString().slice(2) id = Math.random().toString().slice(2)
}: SubscriptionOptions = {} }: SubscriptionOptions = {}
): Sub<K> => { ): Sub => {
let subid = id let subid = id
openSubs[subid] = { openSubs[subid] = {
@@ -263,7 +233,7 @@ export function relayInit(
skipVerification, skipVerification,
alreadyHaveEvent alreadyHaveEvent
} }
trySend([verb, subid, ...filters]) trySend(['REQ', subid, ...filters])
return { return {
sub: (newFilters, newOpts = {}) => sub: (newFilters, newOpts = {}) =>
@@ -277,15 +247,20 @@ export function relayInit(
delete subListeners[subid] delete subListeners[subid]
trySend(['CLOSE', subid]) trySend(['CLOSE', subid])
}, },
on: (type, cb) => { on: <T extends keyof SubEvent, U extends SubEvent[T]>(
type: T,
cb: U
): void => {
subListeners[subid] = subListeners[subid] || { subListeners[subid] = subListeners[subid] || {
event: [], event: [],
count: [],
eose: [] eose: []
} }
subListeners[subid][type].push(cb) subListeners[subid][type].push(cb)
}, },
off: (type, cb): void => { off: <T extends keyof SubEvent, U extends SubEvent[T]>(
type: T,
cb: U
): void => {
let listeners = subListeners[subid] let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb) let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1) if (idx >= 0) listeners[type].splice(idx, 1)
@@ -293,29 +268,6 @@ export function relayInit(
} }
} }
function _publishEvent(event: Event<number>, type: string) {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend([type, event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
return { return {
url, url,
sub, sub,
@@ -336,10 +288,10 @@ export function relayInit(
let index = listeners[type].indexOf(cb) let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1) if (index !== -1) listeners[type].splice(index, 1)
}, },
list: (filters, opts?: SubscriptionOptions) => list: (filters: Filter[], opts?: SubscriptionOptions): Promise<Event[]> =>
new Promise(resolve => { new Promise(resolve => {
let s = sub(filters, opts) let s = sub(filters, opts)
let events: Event<any>[] = [] let events: Event[] = []
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
s.unsub() s.unsub()
resolve(events) resolve(events)
@@ -349,45 +301,48 @@ export function relayInit(
clearTimeout(timeout) clearTimeout(timeout)
resolve(events) resolve(events)
}) })
s.on('event', (event) => { s.on('event', (event: Event) => {
events.push(event) events.push(event)
}) })
}), }),
get: (filter, opts?: SubscriptionOptions) => get: (filter: Filter, opts?: SubscriptionOptions): Promise<Event | null> =>
new Promise(resolve => { new Promise(resolve => {
let s = sub([filter], opts) let s = sub([filter], opts)
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
s.unsub() s.unsub()
resolve(null) resolve(null)
}, getTimeout) }, getTimeout)
s.on('event', (event) => { s.on('event', (event: Event) => {
s.unsub() s.unsub()
clearTimeout(timeout) clearTimeout(timeout)
resolve(event) resolve(event)
}) })
}), }),
count: (filters: Filter[]): Promise<CountPayload | null> => publish(event: Event): Pub {
new Promise(resolve => { if (!event.id) throw new Error(`event ${event} has no id`)
let s = sub(filters, {...sub, verb: 'COUNT'}) let id = event.id
let timeout = setTimeout(() => {
s.unsub() trySend(['EVENT', event])
resolve(null)
}, countTimeout) return {
s.on('count', (event: CountPayload) => { on: (type: 'ok' | 'failed', cb: any) => {
s.unsub() pubListeners[id] = pubListeners[id] || {
clearTimeout(timeout) ok: [],
resolve(event) failed: []
}) }
}), pubListeners[id][type].push(cb)
publish(event): Pub { },
return _publishEvent(event, 'EVENT') off: (type: 'ok' | 'failed', cb: any) => {
}, let listeners = pubListeners[id]
auth(event): Pub { if (!listeners) return
return _publishEvent(event, 'AUTH') let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}, },
connect, connect,
close(): void { close(): void {
listeners = newListeners() listeners = {connect: [], disconnect: [], error: [], notice: []}
subListeners = {} subListeners = {}
pubListeners = {} pubListeners = {}
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {

View File

@@ -1,17 +0,0 @@
import type {Event} from './event.ts'
type EventParams<K extends number> = Partial<Event<K>>
/** Build an event for testing purposes. */
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
return {
id: '',
kind: 1 as K,
pubkey: '',
created_at: 0,
content: '',
tags: [],
sig: '',
...params
}
}

View File

@@ -10,7 +10,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"outDir": "lib", "outDir": "lib",
"rootDir": ".", "rootDir": "."
"allowImportingTsExtensions": true
} }
} }

183
utils.test.js Normal file
View File

@@ -0,0 +1,183 @@
/* eslint-env jest */
const {utils} = require('./lib/nostr.cjs')
const {insertEventIntoAscendingList, insertEventIntoDescendingList} = utils
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0 = []
expect(
insertEventIntoDescendingList(list0, {id: 'abc', created_at: 10})
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [{created_at: 20}, {created_at: 10}]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [{created_at: 30}, {created_at: 20}, {created_at: 10}]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
{created_at: 30},
{created_at: 20},
{created_at: 10},
{created_at: 1}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 15
})
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 10}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 5
})
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 10}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 10, id: 'abc'}
]
const list1 = insertEventIntoDescendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0 = []
expect(
insertEventIntoAscendingList(list0, {id: 'abc', created_at: 10})
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [{created_at: 10}, {created_at: 20}]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 1
})
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [{created_at: 10}, {created_at: 20}, {created_at: 30}]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 10
})
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
{created_at: 10},
{created_at: 20},
{created_at: 30},
{created_at: 40}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 25
})
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 40}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 50
})
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 20},
{created_at: 30}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
{created_at: 20},
{created_at: 20},
{created_at: 30, id: 'abc'}
]
const list1 = insertEventIntoAscendingList(list0, {
id: 'abc',
created_at: 30
})
expect(list1).toHaveLength(3)
})
})

View File

@@ -1,239 +0,0 @@
import {buildEvent} from './test-helpers.ts'
import {
MessageQueue,
insertEventIntoAscendingList,
insertEventIntoDescendingList,
} from './utils.ts'
import type {Event} from './event.ts'
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(
insertEventIntoDescendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 20}), buildEvent({created_at: 10})]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 30}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({created_at: 30}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
buildEvent({created_at: 1}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 15
}))
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 5
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0: Event[] = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10, id: 'abc'}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(
insertEventIntoAscendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 10}), buildEvent({created_at: 20})]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 1
}))
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 10}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({created_at: 10}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
buildEvent({created_at: 40}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 25
}))
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 40}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 50
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30, id: 'abc'}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(3)
})
})
describe('enque a message into MessageQueue', () => {
test('enque into an empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
expect(queue.first!.value).toBe('node1')
})
test('enque into a non-empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
expect(queue.first!.value).toBe('node1')
expect(queue.last!.value).toBe('node2')
expect(queue.size).toBe(3)
})
test('dequeue from an empty queue', () => {
const queue = new MessageQueue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
expect(queue.size).toBe(0)
})
test('dequeue from a non-empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
})
test('dequeue more than in queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
expect(queue.size).toBe(0)
const item3 = queue.dequeue()
expect(item3).toBe(null)
})
})

View File

@@ -1,4 +1,4 @@
import type {Event} from './event.ts' import {Event} from './event'
export const utf8Decoder = new TextDecoder('utf-8') export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder() export const utf8Encoder = new TextEncoder()
@@ -21,8 +21,8 @@ export function normalizeURL(url: string): string {
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array // fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
// //
export function insertEventIntoDescendingList( export function insertEventIntoDescendingList(
sortedArray: Event<number>[], sortedArray: Event[],
event: Event<number> event: Event
) { ) {
let start = 0 let start = 0
let end = sortedArray.length - 1 let end = sortedArray.length - 1
@@ -66,8 +66,8 @@ export function insertEventIntoDescendingList(
} }
export function insertEventIntoAscendingList( export function insertEventIntoAscendingList(
sortedArray: Event<number>[], sortedArray: Event[],
event: Event<number> event: Event
) { ) {
let start = 0 let start = 0
let end = sortedArray.length - 1 let end = sortedArray.length - 1
@@ -109,79 +109,3 @@ export function insertEventIntoAscendingList(
return sortedArray return sortedArray
} }
export class MessageNode {
private _value: string
private _next: MessageNode | null
public get value(): string {
return this._value
}
public set value(message: string) {
this._value = message
}
public get next(): MessageNode | null {
return this._next
}
public set next(node: MessageNode | null) {
this._next = node
}
constructor(message: string) {
this._value = message
this._next = null
}
}
export class MessageQueue {
private _first: MessageNode | null
private _last: MessageNode | null
public get first(): MessageNode | null {
return this._first
}
public set first(messageNode: MessageNode | null) {
this._first = messageNode
}
public get last(): MessageNode | null {
return this._last
}
public set last(messageNode: MessageNode | null) {
this._last = messageNode
}
private _size: number
public get size(): number {
return this._size
}
public set size(v: number) {
this._size = v
}
constructor() {
this._first = null
this._last = null
this._size = 0
}
enqueue(message: string): boolean {
const newNode = new MessageNode(message)
if (this._size === 0 || !this._last) {
this._first = newNode
this._last = newNode
} else {
this._last.next = newNode
this._last = newNode
}
this._size++
return true
}
dequeue(): string | null {
if (this._size === 0 || !this._first) return null
let prev = this._first
this._first = prev.next
prev.next = null
this._size--
return prev.value
}
}

4100
yarn.lock

File diff suppressed because it is too large Load Diff