Skip to content

Commit 7409006

Browse files
feat(nip77): add NEG-OPEN/NEG-MSG/NEG-CLOSE wire protocol (#818)
* feat(nip77): add negentropy wire messages and settings Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * feat(nip77): serve negentropy sessions over the websocket Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * test(nip77): cover negentropy wire protocol and sessions Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> --------- Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent 82287d4 commit 7409006

33 files changed

Lines changed: 2065 additions & 7 deletions

‎.changeset/nip77-wire-protocol.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(nip77): serve NEG-OPEN, NEG-MSG and NEG-CLOSE negentropy sync for the PostgreSQL event store

‎.knip.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
],
1616
"ignore": [
1717
".nostr/**",
18-
"src/utils/negentropy.ts",
1918
"src/utils/relay-probe/**"
2019
],
2120
"commitlint": false,

‎CONFIGURATION.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ The settings below are listed in alphabetical order by name. Please keep this ta
232232
| nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. |
233233
| nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. |
234234
| nip66.timeouts.wsRttMs | WebSocket open RTT probe timeout in milliseconds. Defaults to 10000. |
235+
| nip77.enabled | Enable NIP-77 (Negentropy) set-reconciliation sync, so clients and other relays can find which events differ without downloading them all. Defaults to false. Only answers when `eventStore.backend` is `postgres`; other backends bring their own negentropy implementation. When `nip42.restrictedReads` is enabled, reconciliation would list the ids of restricted events, so a `NEG-OPEN` filter that could match one (it names a restricted kind or no kinds at all) is only accepted when scoped by `authors` or `#p` to pubkeys the client has authenticated as, and is re-checked on every `NEG-MSG`; otherwise the session is closed with `NEG-ERR auth-required:`. Each open session holds the ids of every event matching its filter in memory, so size the limits below for your hardware. |
236+
| nip77.maxEventsPerSession | Most events one session may cover. A `NEG-OPEN` whose filter matches more is rejected with `NEG-ERR blocked:` (clients should split the sync into `since`/`until` windows). The filter's `limit` is ignored, since reconciliation needs the whole set. Defaults to 100000. |
237+
| nip77.maxSessionsPerConnection | Concurrent negentropy sessions one websocket connection may hold; opening an id that is already open replaces it. Further `NEG-OPEN`s are rejected with `NEG-ERR blocked:`. Defaults to 3. |
238+
| nip77.sessionIdleTimeoutMs | A session that receives no `NEG-MSG` for this many milliseconds is closed with `NEG-ERR closed:`. Defaults to 60000. |
235239
| paymentProcessors.lnbits.baseURL | Base URL of your Lnbits instance. |
236240
| paymentProcessors.lnbits.callbackBaseURL | Public-facing Nostream's Lnbits Callback URL. (e.g. https://relay.your-domain.com/callbacks/lnbits) |
237241
| paymentProcessors.lnurl.invoiceURL | [LUD-06 Pay Request](https://github.com/lnurl/luds/blob/luds/06.md) provider URL. (e.g. https://getalby.com/lnurlp/your-username) |

‎resources/default-settings.yaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,15 @@ nip56:
148148
# When true, events matching an actionable report are excluded from
149149
# REQ/COUNT results. Has no effect unless enabled is also true.
150150
hideActionableReports: false
151+
nip77:
152+
# NIP-77 negentropy sync. Only answers when eventStore.backend is postgres.
153+
enabled: false
154+
# Most events a single session may hold in memory.
155+
maxEventsPerSession: 100000
156+
# Concurrent negentropy sessions per connection.
157+
maxSessionsPerConnection: 3
158+
# Close a session with no NEG-MSG for this long.
159+
sessionIdleTimeoutMs: 60000
151160
network:
152161
maxPayloadSize: 524288
153162
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.

‎src/@types/adapters.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EventEmitter } from 'node:stream'
2+
import { INegentropySessions } from './negentropy'
23
import { SubscriptionFilter } from './subscription'
34

45
export interface IWebSocketServerAdapter extends EventEmitter, IWebServerAdapter {
@@ -20,6 +21,8 @@ export type IWebSocketAdapter = EventEmitter & {
2021
/** Returns false if this AUTH event id was already accepted on this socket. */
2122
addAuthenticatedPubkey(pubkey: string, authEventId: string): boolean
2223
drainAndClose(reason?: string): void
24+
/** NIP-77 sessions of this connection. */
25+
getNegentropySessions(): INegentropySessions
2326
}
2427

2528
export interface ICacheAdapter {

‎src/@types/messages.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,35 @@ export enum MessageType {
1313
COUNT = 'COUNT',
1414
CLOSED = 'CLOSED',
1515
AUTH = 'AUTH',
16+
NEG_OPEN = 'NEG-OPEN',
17+
NEG_MSG = 'NEG-MSG',
18+
NEG_CLOSE = 'NEG-CLOSE',
19+
NEG_ERR = 'NEG-ERR',
1620
}
1721

18-
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage | AuthMessage) & {
22+
export type IncomingMessage = (
23+
| SubscribeMessage
24+
| IncomingEventMessage
25+
| UnsubscribeMessage
26+
| CountMessage
27+
| AuthMessage
28+
| NegOpenMessage
29+
| NegMsgMessage
30+
| NegCloseMessage
31+
) & {
1932
[ContextMetadataKey]?: ContextMetadata
2033
}
2134

22-
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage | AuthChallengeMessage
35+
export type OutgoingMessage =
36+
| OutgoingEventMessage
37+
| EndOfStoredEventsNotice
38+
| NoticeMessage
39+
| CommandResult
40+
| CountResultMessage
41+
| ClosedMessage
42+
| AuthChallengeMessage
43+
| NegMsgMessage
44+
| NegErrMessage
2345

2446
export type SubscribeMessage = {
2547
[index in Range<2, 100>]: SubscriptionFilter
@@ -103,3 +125,32 @@ export interface AuthChallengeMessage {
103125
0: MessageType.AUTH
104126
1: string
105127
}
128+
129+
// NIP-77
130+
export interface NegOpenMessage {
131+
0: MessageType.NEG_OPEN
132+
1: SubscriptionId
133+
2: SubscriptionFilter
134+
/** Hex-encoded initial negentropy message. */
135+
3: string
136+
}
137+
138+
/** Sent in both directions; the payload is a hex-encoded negentropy message. */
139+
export interface NegMsgMessage {
140+
0: MessageType.NEG_MSG
141+
1: SubscriptionId
142+
2: string
143+
}
144+
145+
export interface NegCloseMessage {
146+
0: MessageType.NEG_CLOSE
147+
1: SubscriptionId
148+
}
149+
150+
export interface NegErrMessage {
151+
0: MessageType.NEG_ERR
152+
1: SubscriptionId
153+
2: string
154+
/** For `blocked:` replies, the most records a session may hold. */
155+
3?: number
156+
}

‎src/@types/negentropy.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Negentropy } from '../utils/negentropy'
2+
import { SubscriptionFilter, SubscriptionId } from './subscription'
3+
4+
/** Proves a caller still owns a subscription id after awaiting; see `INegentropySessions.reserve`. */
5+
export type NegentropySessionToken = symbol
6+
7+
export interface NegentropySession {
8+
negentropy: Negentropy
9+
/** The filter the session was opened with, so access can be re-checked on every message. */
10+
filter: SubscriptionFilter
11+
}
12+
13+
export interface INegentropySessions {
14+
/**
15+
* Claims `subscriptionId` for a new session, closing any session already open under that id.
16+
* Returns undefined when the connection already holds `maxLimit` other sessions, or already has
17+
* `maxLimit` event reads in flight -- including reads of requests that have since been replaced,
18+
* which keep running until `endRead` is called. The caller must call `endRead` for a token it got.
19+
*/
20+
reserve(subscriptionId: SubscriptionId, maxLimit: number): NegentropySessionToken | undefined
21+
/** Marks the event read started for a reservation as finished, successfully or not. */
22+
endRead(token: NegentropySessionToken): void
23+
/** Whether `token` is still the reservation for `subscriptionId`, i.e. nobody replaced or closed it. */
24+
isCurrent(subscriptionId: SubscriptionId, token: NegentropySessionToken): boolean
25+
/**
26+
* Turns a reservation into a live session. Returns false when the reservation was closed or
27+
* replaced while the caller was busy (e.g. reading events), in which case nothing is stored.
28+
*/
29+
activate(
30+
subscriptionId: SubscriptionId,
31+
token: NegentropySessionToken,
32+
session: NegentropySession,
33+
idleTimeoutMs: number,
34+
): boolean
35+
/** The live session for an id, with its idle timer restarted. */
36+
touch(subscriptionId: SubscriptionId): NegentropySession | undefined
37+
/** Drops a reservation that never became a session; a no-op if it has since been replaced. */
38+
release(subscriptionId: SubscriptionId, token: NegentropySessionToken): void
39+
close(subscriptionId: SubscriptionId): boolean
40+
closeAll(): void
41+
getSessionIds(): SubscriptionId[]
42+
}

‎src/@types/repositories.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface IEventRepository {
4444
upsertMany(events: Event[]): Promise<number>
4545
findByFilters(filters: SubscriptionFilter[]): IQueryResult<DBEvent[]>
4646
countByFilters(filters: SubscriptionFilter[]): Promise<number>
47-
findReconciliationItems(filter: SubscriptionFilter): IQueryResult<ReconciliationItem[]>
47+
findReconciliationItems(filter: SubscriptionFilter, limit?: number): IQueryResult<ReconciliationItem[]>
4848
deleteByPubkeyAndIds(pubkey: Pubkey, ids: EventId[]): Promise<number>
4949
deleteByPubkeyExceptKinds(pubkey: Pubkey, excludedKinds: number[]): Promise<number>
5050
hasActiveRequestToVanish(pubkey: Pubkey): Promise<boolean>

‎src/@types/settings.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,24 @@ export interface Nip45Settings {
314314
enabled?: boolean
315315
}
316316

317+
export interface Nip77Settings {
318+
/**
319+
* NIP-77 negentropy sync. Defaults to false. Even when true it only answers
320+
* if `eventStore.backend` is postgres: other backends (e.g. strfry) bring
321+
* their own negentropy implementation.
322+
*/
323+
enabled?: boolean
324+
/**
325+
* Most events one session may hold in memory; larger queries are rejected
326+
* with `NEG-ERR blocked:`. Defaults to 100000.
327+
*/
328+
maxEventsPerSession?: number
329+
/** Concurrent negentropy sessions allowed on one connection. Defaults to 3. */
330+
maxSessionsPerConnection?: number
331+
/** A session with no `NEG-MSG` for this long is closed with `NEG-ERR closed:`. Defaults to 60000. */
332+
sessionIdleTimeoutMs?: number
333+
}
334+
317335
export interface Nip50Settings {
318336
enabled?: boolean
319337
language?: string
@@ -491,5 +509,6 @@ export interface Settings {
491509
nip50?: Nip50Settings
492510
nip56?: Nip56Settings
493511
nip66?: Nip66Settings
512+
nip77?: Nip77Settings
494513
wot?: WoTSettings
495514
}

‎src/adapters/web-socket-adapter.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { ContextMetadata, Factory } from '../@types/base'
88
import {
99
createAuthChallengeMessage,
1010
createClosedMessage,
11+
createNegErrMessage,
1112
createNoticeMessage,
1213
createOutgoingEventMessage,
1314
} from '../utils/messages'
1415
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
1516
import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages'
17+
import { INegentropySessions } from '../@types/negentropy'
18+
import { NegentropySessions } from '../utils/negentropy-sessions'
1619
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
1720
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
1821
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
@@ -42,6 +45,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
4245
private alive: boolean
4346
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
4447
private readonly session: Nip42SessionManager
48+
// NIP-77
49+
private readonly negentropySessions: NegentropySessions
4550

4651
public constructor(
4752
private readonly client: WebSocket,
@@ -54,6 +59,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
5459
super()
5560
this.alive = true
5661
this.subscriptions = new Map()
62+
this.negentropySessions = new NegentropySessions((subscriptionId) =>
63+
this.sendMessage(createNegErrMessage(subscriptionId, 'closed: negentropy session timed out')),
64+
)
5765

5866
this.clientId = Buffer.from(this.request.headers['sec-websocket-key'] as string, 'base64').toString('hex')
5967

@@ -175,11 +183,21 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
175183
this.sendMessage(createClosedMessage(subscriptionId, `error: ${reason}`))
176184
})
177185

186+
this.negentropySessions.getSessionIds().forEach((subscriptionId) => {
187+
this.sendMessage(createNegErrMessage(subscriptionId, `closed: ${reason}`))
188+
})
189+
this.negentropySessions.closeAll()
190+
178191
if (this.client.readyState === WebSocket.OPEN) {
179192
this.client.close(1001, reason)
180193
}
181194
}
182195

196+
// NIP-77
197+
public getNegentropySessions(): INegentropySessions {
198+
return this.negentropySessions
199+
}
200+
183201
// NIP-42
184202
public getChallenge(): string {
185203
return this.session.getChallenge()
@@ -349,6 +367,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
349367
recordWebsocketConnectionClosed()
350368
this.alive = false
351369
this.subscriptions.clear()
370+
this.negentropySessions.closeAll()
352371
this.session.clear()
353372

354373
const handlers = abortableMessageHandlers.get(this.client)

0 commit comments

Comments
 (0)