Skip to content

Commit f0bcdcc

Browse files
feat(nip77): advertise NIP-77 in NIP-11 and expose its settings in the admin console (#830)
* feat(nip77): advertise NIP-77 in NIP-11 supported_nips when active Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> * feat(nip77): expose nip77 settings in the admin console Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> --------- Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com> Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent 13584bf commit f0bcdcc

8 files changed

Lines changed: 117 additions & 9 deletions

File tree

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): advertise NIP-77 in NIP-11 supported_nips while it is active and expose its settings in the admin console

‎CONFIGURATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ 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. |
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. While active, the relay lists `77` in the NIP-11 `supported_nips`; it is left out when this is off or another backend is used. All `nip77.*` settings can be edited in the admin console and the CLI settings menu. 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. |
236236
| 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. |
237237
| 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. |
238238
| nip77.sessionIdleTimeoutMs | A session that receives no `NEG-MSG` for this many milliseconds is closed with `NEG-ERR closed:`. Defaults to 60000. |

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
44,
2929
45,
3030
50,
31-
65
31+
65,
32+
77
3233
],
3334
"supportedNipExtensions": [],
3435
"supportedMips": [

‎src/handlers/request-handlers/root-request-handler.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DEFAULT_FILTER_LIMIT } from '../../constants/base'
77
import { fromBech32 } from '../../utils/transform'
88
import { getTemplate } from '../../utils/template-cache'
99
import { getPublicPathPrefix, joinPathPrefix } from '../../utils/http'
10+
import { isNip77Active } from '../../utils/nip77'
1011
import { resolveRelaySelfPubkey } from '../../utils/nip43'
1112
import packageJson from '../../../package.json'
1213

@@ -88,10 +89,11 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
8889
...(self !== undefined ? { self } : {}),
8990
contact,
9091
// NIP-43: clients MUST only send join requests to relays advertising 43,
91-
// so only advertise it when the feature is actually enabled
92-
supported_nips: settings.nip43?.enabled === true
93-
? packageJson.supportedNips
94-
: packageJson.supportedNips.filter((nip: number) => nip !== 43),
92+
// so only advertise it when the feature is actually enabled. Likewise 77
93+
// only while this relay answers NEG-OPEN itself (enabled, PostgreSQL backend).
94+
supported_nips: packageJson.supportedNips.filter(
95+
(nip: number) => (nip !== 43 || settings.nip43?.enabled === true) && (nip !== 77 || isNip77Active(settings)),
96+
),
9597
supported_nip_extensions: packageJson.supportedNipExtensions,
9698
supported_mips: packageJson.supportedMips,
9799
software: packageJson.repository.url,

‎src/utils/settings-guided-schema.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ export const requireSafeNonNegativeIntegerSettingValue = (value: string): string
3535
return result.success ? undefined : result.error.issues[0].message
3636
}
3737

38+
const positiveSafeIntegerSettingValueSchema = z
39+
.string()
40+
.trim()
41+
.regex(/^\d+$/, 'Value must be a positive integer')
42+
.transform(Number)
43+
.pipe(
44+
z
45+
.number()
46+
.int()
47+
.min(1, 'Value must be a positive integer')
48+
.max(Number.MAX_SAFE_INTEGER, 'Value must be a safe integer'),
49+
)
50+
51+
export const requireSafePositiveIntegerSettingValue = (value: string): string | undefined => {
52+
const result = positiveSafeIntegerSettingValueSchema.safeParse(value)
53+
return result.success ? undefined : result.error.issues[0].message
54+
}
55+
3856
export const guidedSettingCategories: GuidedSettingCategory[] = [
3957
{
4058
value: 'relay-info',
@@ -254,6 +272,25 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [
254272
path: 'nip56.hideActionableReports',
255273
type: 'boolean',
256274
},
275+
{ label: 'Enable NIP-77 negentropy sync (PostgreSQL backend only)', path: 'nip77.enabled', type: 'boolean' },
276+
{
277+
label: 'NIP-77 max events per session',
278+
path: 'nip77.maxEventsPerSession',
279+
type: 'number',
280+
validate: requireSafePositiveIntegerSettingValue,
281+
},
282+
{
283+
label: 'NIP-77 max sessions per connection',
284+
path: 'nip77.maxSessionsPerConnection',
285+
type: 'number',
286+
validate: requireSafePositiveIntegerSettingValue,
287+
},
288+
{
289+
label: 'NIP-77 session idle timeout (ms)',
290+
path: 'nip77.sessionIdleTimeoutMs',
291+
type: 'number',
292+
validate: requireSafePositiveIntegerSettingValue,
293+
},
257294
],
258295
},
259296
{

‎test/integration/features/nip-11/nip-11.feature.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ Then('the relay information document contains the required fields', function(thi
6767

6868
Then('the supported_nips field matches the NIPs declared in package.json', function(this: World<Record<string, any>>) {
6969
const doc = this.parameters.httpResponse.data
70-
// NIP-43 is only advertised when enabled in settings; the integration
71-
// relay runs with NIP-43 disabled, so it is filtered from the document.
72-
expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43))
70+
// NIP-43 and NIP-77 are only advertised when enabled in settings; the integration
71+
// relay runs with both disabled, so they are filtered from the document.
72+
expect(doc.supported_nips).to.deep.equal(packageJson.supportedNips.filter((nip: number) => nip !== 43 && nip !== 77))
7373
})
7474

7575
Then('the response body is not a relay information document', function(this: World<Record<string, any>>) {

‎test/unit/handlers/request-handlers/root-request-handler.spec.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
rootRequestHandler,
1313
} from '../../../../src/handlers/request-handlers/root-request-handler'
1414
import { DEFAULT_FILTER_LIMIT } from '../../../../src/constants/base'
15+
import * as eventStoreUtils from '../../../../src/utils/event-store'
1516
import * as eventUtils from '../../../../src/utils/event'
1617
import { toBech32 } from '../../../../src/utils/transform'
1718

@@ -265,6 +266,47 @@ describe('rootRequestHandler', () => {
265266
expect(doc.limitation.default_limit).to.equal(DEFAULT_FILTER_LIMIT)
266267
})
267268

269+
describe('supported_nips and NIP-77', () => {
270+
const advertised = (settings: any) => {
271+
createSettingsStub.returns(settings)
272+
rootRequestHandler(req, res, next)
273+
return res.send.lastCall.args[0].supported_nips
274+
}
275+
276+
it('advertises 77 when NIP-77 is enabled on the PostgreSQL backend', () => {
277+
expect(advertised({ ...baseSettings, nip77: { enabled: true } })).to.include(77)
278+
expect(
279+
advertised({ ...baseSettings, nip77: { enabled: true }, eventStore: { backend: 'postgres' } }),
280+
).to.include(77)
281+
})
282+
283+
it('does not advertise 77 when NIP-77 is disabled or unset', () => {
284+
expect(advertised(baseSettings)).to.not.include(77)
285+
expect(advertised({ ...baseSettings, nip77: { enabled: false } })).to.not.include(77)
286+
})
287+
288+
it('does not advertise 77 when another event store backend is used', () => {
289+
// postgres is the only backend the settings accept today; simulate the strfry backend
290+
// that will bring its own negentropy implementation.
291+
const backendStub = sinon.stub(eventStoreUtils, 'getEventStoreBackend').returns('strfry' as any)
292+
try {
293+
expect(advertised({ ...baseSettings, nip77: { enabled: true } })).to.not.include(77)
294+
} finally {
295+
backendStub.restore()
296+
}
297+
})
298+
299+
it('still gates 43 on NIP-43 independently of 77', () => {
300+
const both = advertised({ ...baseSettings, nip43: { enabled: true }, nip77: { enabled: true } })
301+
expect(both).to.include(43)
302+
expect(both).to.include(77)
303+
304+
const onlyNip77 = advertised({ ...baseSettings, nip77: { enabled: true } })
305+
expect(onlyNip77).to.not.include(43)
306+
expect(onlyNip77).to.include(77)
307+
})
308+
})
309+
268310
it('sets limitation.search_supported to false when NIP-50 is disabled', () => {
269311
rootRequestHandler(req, res, next)
270312

‎test/unit/utils/settings-config.spec.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
guidedSettingCategories,
2323
requireNonEmptySettingValue,
2424
requireSafeNonNegativeIntegerSettingValue,
25+
requireSafePositiveIntegerSettingValue,
2526
} from '../../../src/utils/settings-guided-schema'
2627

2728
describe('settings-config', () => {
@@ -446,6 +447,26 @@ describe('settings-guided-schema', () => {
446447
expect(requireNonEmptySettingValue(' ')).to.equal('Value is required')
447448
})
448449

450+
it('validates guided positive integer fields', () => {
451+
expect(requireSafePositiveIntegerSettingValue('bad')).to.equal('Value must be a positive integer')
452+
expect(requireSafePositiveIntegerSettingValue('-1')).to.equal('Value must be a positive integer')
453+
expect(requireSafePositiveIntegerSettingValue('0')).to.equal('Value must be a positive integer')
454+
expect(requireSafePositiveIntegerSettingValue('9007199254740993')).to.equal('Value must be a safe integer')
455+
expect(requireSafePositiveIntegerSettingValue(' 60000 ')).to.equal(undefined)
456+
})
457+
458+
it('exposes the nip77 settings with positive integer validation', () => {
459+
const category = 'nip-features'
460+
461+
expect(getGuidedSettingField(category, 'nip77.enabled')?.type).to.equal('boolean')
462+
for (const key of ['maxEventsPerSession', 'maxSessionsPerConnection', 'sessionIdleTimeoutMs']) {
463+
const field = getGuidedSettingField(category, `nip77.${key}`)
464+
expect(field?.type, key).to.equal('number')
465+
expect(field?.validate?.('0'), key).to.equal('Value must be a positive integer')
466+
expect(field?.validate?.('3'), key).to.equal(undefined)
467+
}
468+
})
469+
449470
it('does not create settings.yaml when ensuring config dir exists', () => {
450471
const originalConfigDir = process.env.NOSTR_CONFIG_DIR
451472
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-settings-config-'))

0 commit comments

Comments
 (0)