Compare commits

..

13 Commits

Author SHA1 Message Date
fiatjaf
c9ff51e278 subscribeMap() now sends multiple filters to the same relay in the same REQ.
because the initiative to get rid of multiple filters went down.
2025-09-20 16:54:12 -03:00
Anderson Juhasc
23aebbd341 update NIP-27 example in README 2025-08-27 10:32:45 -03:00
Anderson Juhasc
a3fcd79545 ensures consistency for .jpg/.JPG, .mp4/.MP4, etc 2025-08-27 10:32:45 -03:00
tajava2006
0e6e7af934 chore: Bump version and document NIP-46 usage 2025-08-25 11:00:06 -03:00
codytseng
8866042edf relay: ensure onclose callback is triggered 2025-08-24 22:22:38 -03:00
hoppe
ebe7df7b9e feat(nip46): Add support for client-initiated connections in BunkerSigner (#502)
* add: nostrconnect

* fix: typo
2025-08-24 15:53:01 -03:00
fiatjaf
86235314c4 deduplicate relay URLs in pool.subscribe() and pool.subscribeMany() 2025-08-06 10:37:36 -03:00
Don
b39dac3551 nip57: include "e" tag. 2025-08-04 15:23:29 -03:00
fiatjaf
929d62bbbb nip57: cleanup useless tests. 2025-08-01 20:28:49 -03:00
fiatjaf
b575e47844 nip57: include "k" tag. 2025-08-01 19:38:03 -03:00
fiatjaf
b076c34a2f tag new minor because of the pingpong stuff. 2025-08-01 14:12:53 -03:00
fiatjaf
4bb3eb2d40 remove unnecessary normalizeURL() call that can throw sometimes. 2025-08-01 14:11:44 -03:00
Chris McCormick
87f2c74bb3 Get pingpong working in the browser with dummy REQ (#499) 2025-07-24 11:22:15 -03:00
12 changed files with 387 additions and 229 deletions

View File

@@ -133,6 +133,14 @@ import WebSocket from 'ws'
useWebSocketImplementation(WebSocket) useWebSocketImplementation(WebSocket)
``` ```
You can enable regular pings of connected relays with the `enablePing` option. This will set up a heartbeat that closes the websocket if it doesn't receive a response in time. Some platforms don't report websocket disconnections due to network issues, and enabling this can increase reliability.
```js
import { SimplePool } from 'nostr-tools/pool'
const pool = new SimplePool({ enablePing: true })
```
### Parsing references (mentions) from a content based on NIP-27 ### Parsing references (mentions) from a content based on NIP-27
```js ```js
@@ -161,8 +169,10 @@ for (let block of nip27.parse(evt.content)) {
case 'video': case 'video':
case 'audio': case 'audio':
console.log("it's a media url:", block.url) console.log("it's a media url:", block.url)
break
case 'relay': case 'relay':
console.log("it's a websocket url, probably a relay address:", block.url) console.log("it's a websocket url, probably a relay address:", block.url)
break
default: default:
break break
} }
@@ -171,14 +181,24 @@ for (let block of nip27.parse(evt.content)) {
### Connecting to a bunker using NIP-46 ### Connecting to a bunker using NIP-46
`BunkerSigner` allows your application to request signatures and other actions from a remote NIP-46 signer, often called a "bunker". There are two primary ways to establish a connection, depending on whether the client or the bunker initiates the connection.
A local secret key is required for the client to communicate securely with the bunker. This key should generally be persisted for the user's session.
```js
import { generateSecretKey } from '@nostr/tools/pure'
const localSecretKey = generateSecretKey()
```
### Method 1: Using a Bunker URI (`bunker://`)
This is the bunker-initiated flow. Your client receives a `bunker://` string or a NIP-05 identifier from the user. You use `BunkerSigner.fromBunker()` to create an instance, which returns immediately. For the **initial connection** with a new bunker, you must explicitly call `await bunker.connect()` to establish the connection and receive authorization.
```js ```js
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46' import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool' import { SimplePool } from '@nostr/tools/pool'
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
const localSecretKey = generateSecretKey()
// parse a bunker URI // parse a bunker URI
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com') const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
if (!bunkerPointer) { if (!bunkerPointer) {
@@ -187,7 +207,7 @@ if (!bunkerPointer) {
// create the bunker instance // create the bunker instance
const pool = new SimplePool() const pool = new SimplePool()
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool }) const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool })
await bunker.connect() await bunker.connect()
// and use it // and use it
@@ -203,6 +223,47 @@ const event = await bunker.signEvent({
await signer.close() await signer.close()
pool.close([]) pool.close([])
``` ```
> **Note on Reconnecting:** Once a connection has been successfully established and the `BunkerPointer` is stored, you do **not** need to call `await bunker.connect()` on subsequent sessions.
### Method 2: Using a Client-generated URI (`nostrconnect://`)
This is the client-initiated flow, which generally provides a better user experience for first-time connections (e.g., via QR code). Your client generates a `nostrconnect://` URI and waits for the bunker to connect to it.
`BunkerSigner.fromURI()` is an **asynchronous** method. It returns a `Promise` that resolves only after the bunker has successfully connected. Therefore, the returned signer instance is already fully connected and ready to use, so you **do not** need to call `.connect()` on it.
```js
import { getPublicKey } from '@nostr/tools/pure'
import { BunkerSigner, createNostrConnectURI } from '@nostr/tools/nip46'
import { SimplePool } from '@nostr/tools/pool'
const clientPubkey = getPublicKey(localSecretKey)
// generate a connection URI for the bunker to scan
const connectionUri = createNostrConnectURI({
clientPubkey,
relays: ['wss://relay.damus.io', 'wss://relay.primal.net'],
secret: 'a-random-secret-string', // A secret to verify the bunker's response
name: 'My Awesome App'
})
// wait for the bunker to connect
const pool = new SimplePool()
const signer = await BunkerSigner.fromURI(localSecretKey, connectionUri, { pool })
// and use it
const pubkey = await signer.getPublicKey()
const event = await signer.signEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'Hello from a client-initiated connection!'
})
// cleanup
await signer.close()
pool.close([])
```
> **Note on Persistence:** This method is ideal for the initial sign-in. To allow users to stay logged in across sessions, you should store the connection details and use `Method 1` for subsequent reconnections.
### Parsing thread from any note based on NIP-10 ### Parsing thread from any note based on NIP-10

View File

@@ -32,6 +32,7 @@ export class AbstractSimplePool {
public trackRelays: boolean = false public trackRelays: boolean = false
public verifyEvent: Nostr['verifyEvent'] public verifyEvent: Nostr['verifyEvent']
public enablePing: boolean | undefined
public trustedRelayURLs: Set<string> = new Set() public trustedRelayURLs: Set<string> = new Set()
private _WebSocket?: typeof WebSocket private _WebSocket?: typeof WebSocket
@@ -39,6 +40,7 @@ export class AbstractSimplePool {
constructor(opts: AbstractPoolConstructorOptions) { constructor(opts: AbstractPoolConstructorOptions) {
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation this._WebSocket = opts.websocketImplementation
this.enablePing = opts.enablePing
} }
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> { async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<AbstractRelay> {
@@ -49,6 +51,7 @@ export class AbstractSimplePool {
relay = new AbstractRelay(url, { relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent, verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
websocketImplementation: this._WebSocket, websocketImplementation: this._WebSocket,
enablePing: this.enablePing,
}) })
relay.onclose = () => { relay.onclose = () => {
this.relays.delete(url) this.relays.delete(url)
@@ -71,24 +74,44 @@ export class AbstractSimplePool {
subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser { subscribe(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth params.onauth = params.onauth || params.doauth
return this.subscribeMap( const request: { url: string; filter: Filter }[] = []
relays.map(url => ({ url, filter })), for (let i = 0; i < relays.length; i++) {
params, const url = normalizeURL(relays[i])
) if (!request.find(r => r.url === url)) {
request.push({ url, filter: filter })
}
}
return this.subscribeMap(request, params)
} }
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser { subscribeMany(relays: string[], filter: Filter, params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth params.onauth = params.onauth || params.doauth
return this.subscribeMap( const request: { url: string; filter: Filter }[] = []
relays.flatMap(url => filters.map(filter => ({ url, filter }))), const uniqUrls: string[] = []
params, for (let i = 0; i < relays.length; i++) {
) const url = normalizeURL(relays[i])
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url)
request.push({ url, filter: filter })
}
}
return this.subscribeMap(request, params)
} }
subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser { subscribeMap(requests: { url: string; filter: Filter }[], params: SubscribeManyParams): SubCloser {
params.onauth = params.onauth || params.doauth params.onauth = params.onauth || params.doauth
const grouped = new Map<string, Filter[]>()
for (const req of requests) {
const { url, filter } = req
if (!grouped.has(url)) grouped.set(url, [])
grouped.get(url)!.push(filter)
}
const groupedRequests = Array.from(grouped.entries()).map(([url, filters]) => ({ url, filters }))
if (this.trackRelays) { if (this.trackRelays) {
params.receivedEvent = (relay: AbstractRelay, id: string) => { params.receivedEvent = (relay: AbstractRelay, id: string) => {
let set = this.seenOn.get(id) let set = this.seenOn.get(id)
@@ -136,9 +159,7 @@ export class AbstractSimplePool {
// open a subscription in all given relays // open a subscription in all given relays
const allOpened = Promise.all( const allOpened = Promise.all(
requests.map(async ({ url, filter }, i) => { groupedRequests.map(async ({ url, filters }, i) => {
url = normalizeURL(url)
let relay: AbstractRelay let relay: AbstractRelay
try { try {
relay = await this.ensureRelay(url, { relay = await this.ensureRelay(url, {
@@ -149,7 +170,7 @@ export class AbstractSimplePool {
return return
} }
let subscription = relay.subscribe([filter], { let subscription = relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => { onclose: reason => {
@@ -157,7 +178,7 @@ export class AbstractSimplePool {
relay relay
.auth(params.onauth) .auth(params.onauth)
.then(() => { .then(() => {
relay.subscribe([filter], { relay.subscribe(filters, {
...params, ...params,
oneose: () => handleEose(i), oneose: () => handleEose(i),
onclose: reason => { onclose: reason => {
@@ -210,12 +231,12 @@ export class AbstractSimplePool {
subscribeManyEose( subscribeManyEose(
relays: string[], relays: string[],
filters: Filter[], filter: Filter,
params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>, params: Pick<SubscribeManyParams, 'label' | 'id' | 'onevent' | 'onclose' | 'maxWait' | 'onauth' | 'doauth'>,
): SubCloser { ): SubCloser {
params.onauth = params.onauth || params.doauth params.onauth = params.onauth || params.doauth
const subcloser = this.subscribeMany(relays, filters, { const subcloser = this.subscribeMany(relays, filter, {
...params, ...params,
oneose() { oneose() {
subcloser.close('closed automatically on eose') subcloser.close('closed automatically on eose')

View File

@@ -15,6 +15,7 @@ type RelayWebSocket = WebSocket & {
export type AbstractRelayConstructorOptions = { export type AbstractRelayConstructorOptions = {
verifyEvent: Nostr['verifyEvent'] verifyEvent: Nostr['verifyEvent']
websocketImplementation?: typeof WebSocket websocketImplementation?: typeof WebSocket
enablePing?: boolean
} }
export class SendingOnClosedConnection extends Error { export class SendingOnClosedConnection extends Error {
@@ -34,7 +35,10 @@ export class AbstractRelay {
public baseEoseTimeout: number = 4400 public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400 public connectionTimeout: number = 4400
public publishTimeout: number = 4400 public publishTimeout: number = 4400
public pingFrequency: number = 20000
public pingTimeout: number = 20000
public openSubs: Map<string, Subscription> = new Map() public openSubs: Map<string, Subscription> = new Map()
public enablePing: boolean | undefined
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private connectionPromise: Promise<void> | undefined private connectionPromise: Promise<void> | undefined
@@ -54,9 +58,7 @@ export class AbstractRelay {
this.url = normalizeURL(url) this.url = normalizeURL(url)
this.verifyEvent = opts.verifyEvent this.verifyEvent = opts.verifyEvent
this._WebSocket = opts.websocketImplementation || WebSocket this._WebSocket = opts.websocketImplementation || WebSocket
// this.pingHeartBeat = opts.pingHeartBeat this.enablePing = opts.enablePing
// this.pingFrequency = opts.pingFrequency
// this.pingTimeout = opts.pingTimeout
} }
static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> { static async connect(url: string, opts: AbstractRelayConstructorOptions): Promise<AbstractRelay> {
@@ -110,8 +112,7 @@ export class AbstractRelay {
this.ws.onopen = () => { this.ws.onopen = () => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
this._connected = true this._connected = true
if (this.ws && this.ws.ping) { if (this.enablePing) {
// && this.pingHeartBeat
this.pingpong() this.pingpong()
} }
resolve() resolve()
@@ -120,23 +121,19 @@ export class AbstractRelay {
this.ws.onerror = ev => { this.ws.onerror = ev => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket error') reject((ev as any).message || 'websocket error')
if (this._connected) { this._connected = false
this._connected = false this.connectionPromise = undefined
this.connectionPromise = undefined this.onclose?.()
this.onclose?.() this.closeAllSubscriptions('relay connection errored')
this.closeAllSubscriptions('relay connection errored')
}
} }
this.ws.onclose = ev => { this.ws.onclose = ev => {
clearTimeout(this.connectionTimeoutHandle) clearTimeout(this.connectionTimeoutHandle)
reject((ev as any).message || 'websocket closed') reject((ev as any).message || 'websocket closed')
if (this._connected) { this._connected = false
this._connected = false this.connectionPromise = undefined
this.connectionPromise = undefined this.onclose?.()
this.onclose?.() this.closeAllSubscriptions('relay connection closed')
this.closeAllSubscriptions('relay connection closed')
}
} }
this.ws.onmessage = this._onmessage.bind(this) this.ws.onmessage = this._onmessage.bind(this)
@@ -145,9 +142,26 @@ export class AbstractRelay {
return this.connectionPromise return this.connectionPromise
} }
private async receivePong() { private async waitForPingPong() {
return new Promise((res, err) => { return new Promise((res, err) => {
// listen for pong
;(this.ws && this.ws.on && this.ws.on('pong', () => res(true))) || err("ws can't listen for pong") ;(this.ws && this.ws.on && this.ws.on('pong', () => res(true))) || err("ws can't listen for pong")
// send a ping
this.ws && this.ws.ping && this.ws.ping()
})
}
private async waitForDummyReq() {
return new Promise((resolve, _) => {
// make a dummy request with expected empty eose reply
// ["REQ", "_", {"ids":["aaaa...aaaa"]}]
const sub = this.subscribe([{ ids: ['a'.repeat(64)] }], {
oneose: () => {
sub.close()
resolve(true)
},
eoseTimeout: this.pingTimeout + 1000,
})
}) })
} }
@@ -155,21 +169,22 @@ export class AbstractRelay {
// in browsers it's done automatically. see https://github.com/nbd-wtf/nostr-tools/issues/491 // in browsers it's done automatically. see https://github.com/nbd-wtf/nostr-tools/issues/491
private async pingpong() { private async pingpong() {
// if the websocket is connected // if the websocket is connected
if (this.ws?.readyState == 1) { if (this.ws?.readyState === 1) {
// send a ping // wait for either a ping-pong reply or a timeout
this.ws && this.ws.ping && this.ws.ping()
// wait for either a pong or a timeout
const result = await Promise.any([ const result = await Promise.any([
this.receivePong(), // browsers don't have ping so use a dummy req
new Promise(res => setTimeout(() => res(false), 10000)), // TODO: opts.pingTimeout this.ws && this.ws.ping && this.ws.on ? this.waitForPingPong() : this.waitForDummyReq(),
new Promise(res => setTimeout(() => res(false), this.pingTimeout)),
]) ])
console.error('pingpong result', result)
if (result) { if (result) {
// schedule another pingpong // schedule another pingpong
setTimeout(() => this.pingpong(), 10000) // TODO: opts.pingFrequency setTimeout(() => this.pingpong(), this.pingFrequency)
} else { } else {
// pingpong closing socket // pingpong closing socket
this.ws && this.ws.close() this.closeAllSubscriptions('pingpong timed out')
this._connected = false
this.onclose?.()
this.ws?.close()
} }
} }
} }
@@ -359,8 +374,8 @@ export class AbstractRelay {
public close() { public close() {
this.closeAllSubscriptions('relay connection closed by us') this.closeAllSubscriptions('relay connection closed by us')
this._connected = false this._connected = false
this.ws?.close()
this.onclose?.() this.onclose?.()
this.ws?.close()
} }
// this is the function assigned to this.ws.onmessage // this is the function assigned to this.ws.onmessage

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nostr/tools", "name": "@nostr/tools",
"version": "2.15.2", "version": "2.17.0",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./core": "./core.ts", "./core": "./core.ts",

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
// prettier-ignore
import { import {
decode, decode,
naddrEncode, naddrEncode,

View File

@@ -90,35 +90,19 @@ export function* parse(content: string): Iterable<Block> {
yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) } yield { type: 'text', text: content.substring(prevIndex, u - prefixLen) }
} }
if ( if (/\.(png|jpe?g|gif|webp)$/i.test(url.pathname)) {
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.jpg') ||
url.pathname.endsWith('.jpeg') ||
url.pathname.endsWith('.gif') ||
url.pathname.endsWith('.webp')
) {
yield { type: 'image', url: url.toString() } yield { type: 'image', url: url.toString() }
index = end index = end
prevIndex = index prevIndex = index
continue continue
} }
if ( if (/\.(mp4|avi|webm|mkv)$/i.test(url.pathname)) {
url.pathname.endsWith('.mp4') ||
url.pathname.endsWith('.avi') ||
url.pathname.endsWith('.webm') ||
url.pathname.endsWith('.mkv')
) {
yield { type: 'video', url: url.toString() } yield { type: 'video', url: url.toString() }
index = end index = end
prevIndex = index prevIndex = index
continue continue
} }
if ( if (/\.(mp3|aac|ogg|opus)$/i.test(url.pathname)) {
url.pathname.endsWith('.mp3') ||
url.pathname.endsWith('.aac') ||
url.pathname.endsWith('.ogg') ||
url.pathname.endsWith('.opus')
) {
yield { type: 'audio', url: url.toString() } yield { type: 'audio', url: url.toString() }
index = end index = end
prevIndex = index prevIndex = index

203
nip46.ts
View File

@@ -77,6 +77,114 @@ export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer |
} }
} }
export type NostrConnectParams = {
clientPubkey: string
relays: string[]
secret: string
perms?: string[]
name?: string
url?: string
image?: string
}
export type ParsedNostrConnectURI = {
protocol: 'nostrconnect'
clientPubkey: string
params: {
relays: string[]
secret: string
perms?: string[]
name?: string
url?: string
image?: string
}
originalString: string
}
export function createNostrConnectURI(params: NostrConnectParams): string {
if (!params.clientPubkey) {
throw new Error('clientPubkey is required.')
}
if (!params.relays || params.relays.length === 0) {
throw new Error('At least one relay is required.')
}
if (!params.secret) {
throw new Error('secret is required.')
}
const queryParams = new URLSearchParams()
params.relays.forEach(relay => {
queryParams.append('relay', relay)
})
queryParams.append('secret', params.secret)
if (params.perms && params.perms.length > 0) {
queryParams.append('perms', params.perms.join(','))
}
if (params.name) {
queryParams.append('name', params.name)
}
if (params.url) {
queryParams.append('url', params.url)
}
if (params.image) {
queryParams.append('image', params.image)
}
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}`
}
export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI {
if (!uri.startsWith('nostrconnect://')) {
throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".')
}
const [protocolAndPubkey, queryString] = uri.split('?')
if (!protocolAndPubkey || !queryString) {
throw new Error('Invalid nostrconnect URI: Missing query string.')
}
const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length)
if (!clientPubkey) {
throw new Error('Invalid nostrconnect URI: Missing client-pubkey.')
}
const queryParams = new URLSearchParams(queryString)
const relays = queryParams.getAll('relay')
if (relays.length === 0) {
throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.')
}
const secret = queryParams.get('secret')
if (!secret) {
throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.')
}
const permsString = queryParams.get('perms')
const perms = permsString ? permsString.split(',') : undefined
const name = queryParams.get('name') || undefined
const url = queryParams.get('url') || undefined
const image = queryParams.get('image') || undefined
return {
protocol: 'nostrconnect',
clientPubkey,
params: {
relays,
secret,
perms,
name,
url,
image,
},
originalString: uri,
}
}
export type BunkerSignerParams = { export type BunkerSignerParams = {
pool?: AbstractSimplePool pool?: AbstractSimplePool
onauth?: (url: string) => void onauth?: (url: string) => void
@@ -97,8 +205,9 @@ export class BunkerSigner implements Signer {
} }
private waitingForAuth: { [id: string]: boolean } private waitingForAuth: { [id: string]: boolean }
private secretKey: Uint8Array private secretKey: Uint8Array
private conversationKey: Uint8Array // If the client initiates the connection, the two variables below can be filled in later.
public bp: BunkerPointer private conversationKey!: Uint8Array
public bp!: BunkerPointer
private cachedPubKey: string | undefined private cachedPubKey: string | undefined
@@ -108,23 +217,95 @@ export class BunkerSigner implements Signer {
* @param remotePubkey - An optional remote public key. This is the key you want to sign as. * @param remotePubkey - An optional remote public key. This is the key you want to sign as.
* @param secretKey - An optional key pair. * @param secretKey - An optional key pair.
*/ */
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) { private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker')
}
this.params = params this.params = params
this.pool = params.pool || new SimplePool() this.pool = params.pool || new SimplePool()
this.secretKey = clientSecretKey this.secretKey = clientSecretKey
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
this.bp = bp
this.isOpen = false this.isOpen = false
this.idPrefix = Math.random().toString(36).substring(7) this.idPrefix = Math.random().toString(36).substring(7)
this.serial = 0 this.serial = 0
this.listeners = {} this.listeners = {}
this.waitingForAuth = {} this.waitingForAuth = {}
}
this.setupSubscription(params) /**
* [Factory Method 1] Creates a Signer using bunker information (bunker:// URL or NIP-05).
* This method is used when the public key of the bunker is known in advance.
*/
public static fromBunker(
clientSecretKey: Uint8Array,
bp: BunkerPointer,
params: BunkerSignerParams = {},
): BunkerSigner {
if (bp.relays.length === 0) {
throw new Error('No relays specified for this bunker')
}
const signer = new BunkerSigner(clientSecretKey, params)
signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
signer.bp = bp
signer.setupSubscription(params)
return signer
}
/**
* [Factory Method 2] Creates a Signer using a nostrconnect:// URI generated by the client.
* In this method, the bunker initiates the connection by scanning the URI.
*/
public static async fromURI(
clientSecretKey: Uint8Array,
connectionURI: string,
params: BunkerSignerParams = {},
maxWait: number = 300_000,
): Promise<BunkerSigner> {
const signer = new BunkerSigner(clientSecretKey, params)
const parsedURI = parseNostrConnectURI(connectionURI)
const clientPubkey = getPublicKey(clientSecretKey)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
sub.close()
reject(new Error(`Connection timed out after ${maxWait / 1000} seconds`))
}, maxWait)
const sub = signer.pool.subscribe(
parsedURI.params.relays,
{ kinds: [NostrConnect], '#p': [clientPubkey] },
{
onevent: async (event: NostrEvent) => {
try {
const tempConvKey = getConversationKey(clientSecretKey, event.pubkey)
const decryptedContent = decrypt(event.content, tempConvKey)
const response = JSON.parse(decryptedContent)
if (response.result === parsedURI.params.secret) {
clearTimeout(timer)
sub.close()
signer.bp = {
pubkey: event.pubkey,
relays: parsedURI.params.relays,
secret: parsedURI.params.secret,
}
signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey)
signer.setupSubscription(params)
resolve(signer)
}
} catch (e) {
console.warn('Failed to process potential connection event', e)
}
},
onclose: () => {
clearTimeout(timer)
reject(new Error('Subscription closed before connection was established.'))
},
maxWait,
},
)
})
} }
private setupSubscription(params: BunkerSignerParams) { private setupSubscription(params: BunkerSignerParams) {
@@ -290,7 +471,7 @@ export async function createAccount(
): Promise<BunkerSigner> { ): Promise<BunkerSigner> {
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email') if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params) let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']) let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])

View File

@@ -1,112 +1,7 @@
import { describe, test, expect, mock } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { finalizeEvent } from './pure.ts' import { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts' import { getPublicKey, generateSecretKey } from './pure.ts'
import { import { getSatoshisAmountFromBolt11, makeZapReceipt, validateZapRequest } from './nip57.ts'
getSatoshisAmountFromBolt11,
getZapEndpoint,
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import { buildEvent } from './test-helpers.ts'
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({ kind: 0, content: '{}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = mock(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = mock(() => Promise.resolve({ json: () => ({ allowsNostr: false }) }))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = mock(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback',
}),
}),
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({ kind: 0, content: '{"lud16": "name@domain"}' })
const result = await getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith('https://domain/.well-known/lnurlp/name')
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: '',
}),
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: '',
}),
).toThrow()
})
test('returns a valid Zap request', () => {
const result = makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment',
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2'],
['e', 'event'],
]),
)
})
})
describe('validateZapRequest', () => { describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => { test('returns an error message for invalid JSON', () => {

View File

@@ -1,6 +1,6 @@
import { bech32 } from '@scure/base' import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts' import { NostrEvent, validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts' import { utf8Decoder } from './utils.ts'
import { isReplaceableKind, isAddressableKind } from './kinds.ts' import { isReplaceableKind, isAddressableKind } from './kinds.ts'
@@ -42,48 +42,44 @@ export async function getZapEndpoint(metadata: Event): Promise<null | string> {
return null return null
} }
export function makeZapRequest({ type ProfileZap = {
profile, pubkey: string
event,
amount,
relays,
comment = '',
}: {
profile: string
event: string | Event | null
amount: number amount: number
comment: string comment?: string
relays: string[] relays: string[]
}): EventTemplate { }
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
type EventZap = {
event: NostrEvent
amount: number
comment?: string
relays: string[]
}
export function makeZapRequest(params: ProfileZap | EventZap): EventTemplate {
let zr: EventTemplate = { let zr: EventTemplate = {
kind: 9734, kind: 9734,
created_at: Math.round(Date.now() / 1000), created_at: Math.round(Date.now() / 1000),
content: comment, content: params.comment || '',
tags: [ tags: [
['p', profile], ['p', 'pubkey' in params ? params.pubkey : params.event.pubkey],
['amount', amount.toString()], ['amount', params.amount.toString()],
['relays', ...relays], ['relays', ...params.relays],
], ],
} }
if (event && typeof event === 'string') { if ('event' in params) {
zr.tags.push(['e', event]) zr.tags.push(['e', params.event.id])
} if (isReplaceableKind(params.event.kind)) {
if (event && typeof event === 'object') { const a = ['a', `${params.event.kind}:${params.event.pubkey}:`]
// replacable event
if (isReplaceableKind(event.kind)) {
const a = ['a', `${event.kind}:${event.pubkey}:`]
zr.tags.push(a) zr.tags.push(a)
// addressable event } else if (isAddressableKind(params.event.kind)) {
} else if (isAddressableKind(event.kind)) { let d = params.event.tags.find(([t, v]) => t === 'd' && v)
let d = event.tags.find(([t, v]) => t === 'd' && v)
if (!d) throw new Error('d tag not found or is empty') if (!d) throw new Error('d tag not found or is empty')
const a = ['a', `${event.kind}:${event.pubkey}:${d[1]}`] const a = ['a', `${params.event.kind}:${params.event.pubkey}:${d[1]}`]
zr.tags.push(a) zr.tags.push(a)
} }
zr.tags.push(['k', params.event.kind.toString()])
} }
return zr return zr

View File

@@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "nostr-tools", "name": "nostr-tools",
"version": "2.15.2", "version": "2.17.0",
"description": "Tools for making a Nostr client.", "description": "Tools for making a Nostr client.",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -35,14 +35,18 @@ test('removing duplicates when subscribing', async () => {
priv, priv,
) )
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(
onevent(event: Event) { relayURLs,
// this should be called only once even though we're listening { authors: [pub] },
// to multiple relays because the events will be caught and {
// deduplicated efficiently (without even being parsed) onevent(event: Event) {
received.push(event) // this should be called only once even though we're listening
// to multiple relays because the events will be caught and
// deduplicated efficiently (without even being parsed)
received.push(event)
},
}, },
}) )
await Promise.any(pool.publish(relayURLs, event)) await Promise.any(pool.publish(relayURLs, event))
await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received await new Promise(resolve => setTimeout(resolve, 200)) // wait for the new published event to be received
@@ -55,12 +59,12 @@ test('same with double subs', async () => {
let priv = generateSecretKey() let priv = generateSecretKey()
let pub = getPublicKey(priv) let pub = getPublicKey(priv)
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(relayURLs, { authors: [pub] }, {
onevent(event) { onevent(event) {
received.push(event) received.push(event)
}, },
}) })
pool.subscribeMany(relayURLs, [{ authors: [pub] }], { pool.subscribeMany(relayURLs, { authors: [pub] }, {
onevent(event) { onevent(event) {
received.push(event) received.push(event)
}, },
@@ -168,7 +172,7 @@ test('query a bunch of events and cancel on eose', async () => {
let events = new Set<string>() let events = new Set<string>()
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
pool.subscribeManyEose(relayURLs, [{ kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }], { pool.subscribeManyEose(relayURLs, { kinds: [0, 1, 2, 3, 4, 5, 6], limit: 40 }, {
onevent(event) { onevent(event) {
events.add(event.id) events.add(event.id)
}, },

View File

@@ -14,8 +14,8 @@ export function useWebSocketImplementation(websocketImplementation: any) {
} }
export class SimplePool extends AbstractSimplePool { export class SimplePool extends AbstractSimplePool {
constructor() { constructor(options?: { enablePing?: boolean }) {
super({ verifyEvent, websocketImplementation: _WebSocket }) super({ verifyEvent, websocketImplementation: _WebSocket, ...options })
} }
} }