Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/peering-nip11-advertisement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"nostream": minor
---

feat(peering): advertise peering terms and scopes in NIP-11

The relay information document now carries a `peering` section built from the new
`peering` settings block. It advertises whether peering is offered, the feeds
available as `kinds` scopes with an absolute `since`/`until` range or a rolling
`windowSeconds`, terms in the existing NIP-11 fee-object shape (`amount`, `unit`,
`period`) where an empty `terms` list means settlement-free, a peer `contact` or
WebSocket `endpoint`, and `min_pow_difficulty` — the NIP-13 work a peering request
must carry, `0` meaning none.

Disabled peering publishes only `{ "enabled": false }`, so half-configured terms are
never read as an accepted peer: this is the discovery surface other relays read
before connecting, not a peer-acceptance path. Settings validation rejects invalid
scopes, terms, time ranges, endpoints and proof-of-work bounds with a clear path, and
all `peering.*` fields are documented in `CONFIGURATION.md` with an example document.

Closes #932.
42 changes: 42 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,51 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. |
| payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. |
| payments.processor | Either `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `nwc`. |
| peering.contact | Contact URI (e.g. `mailto:peering@your-domain.com`) advertised to other relays for establishing peering. At least one of `contact` or `endpoint` is required when peering is enabled. |
| peering.enabled | Advertise a `peering` section in the NIP-11 document. This only expresses feeds and terms; it does not accept peers. Defaults to false. |
| peering.endpoint | WebSocket endpoint (`ws://` or `wss://`) peers should connect to, advertised to other relays. At least one of `contact` or `endpoint` is required when peering is enabled. |
| peering.minRequestPowDifficulty | NIP-13 proof-of-work bits required on peering requests, advertised as `peering.min_pow_difficulty`. `0` (default) means none. Applies to peering requests only, never to operational traffic. |
| peering.scopes[].kinds | Event kinds this feed covers, as kinds or `[min, max]` ranges. Required for every scope. At least one scope is required when peering is enabled. |
| peering.scopes[].since | Inclusive lower bound of the feed's time range as a unix timestamp in seconds. Omit for no lower bound. Mutually exclusive with `windowSeconds`. |
| peering.scopes[].until | Inclusive upper bound of the feed's time range as a unix timestamp in seconds. Omit for a feed that stays current. Mutually exclusive with `windowSeconds`. |
| peering.scopes[].windowSeconds | Rolling window in seconds: the feed covers the most recent `windowSeconds` of events. Mutually exclusive with `since` and `until`. |
| peering.terms[].amount | Fee amount charged per peering period, denominated in `unit`. |
| peering.terms[].period | Peering period in seconds. |
| peering.terms[].unit | Fee unit (for example `msats`). An empty `terms` list means settlement-free peering. |
| workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. |
| wot.enabled | Enables the Web of Trust graph, rooted at `wot.seedPubkey`, built from NIP-02 contact lists. Defaults to false. |
| wot.maxDepth | How many hops out from `wot.seedPubkey` the trust graph extends. Direct follows are distance 1. Defaults to 2. |
| wot.minimumFollowers | Minimum number of already-trusted accounts that must follow a pubkey before it enters the graph at 2+ hops. Direct follows are always trusted. Defaults to 1. |
| wot.refreshIntervalHours | Reserved for a future periodic full rebuild; not yet read by any code (real-time kind-3 ingestion already keeps the graph current). Defaults to 24. |
| wot.seedPubkey | The relay owner's pubkey in hex. Root of the trust graph. Required when `wot.enabled` is true. |

## Peering advertisement (NIP-11)
Comment thread
Ferryx349 marked this conversation as resolved.

When `peering.enabled` is true, the relay information document served with
`Accept: application/nostr+json` includes a `peering` section built from the
settings above:

```json
{
"peering": {
"enabled": true,
"contact": "mailto:peering@your-domain.com",
"endpoint": "wss://relay.example.com",
"min_pow_difficulty": 0,
"scopes": [
{ "kinds": [1, 6], "window_seconds": 3600 },
{ "kinds": [3, 10002], "since": 1700000000 }
],
"terms": [
{ "amount": 1000, "unit": "msats", "period": 2592000 }
]
}
}
```

`terms` uses the NIP-11 fee-object shape (`amount`, `unit`, `period`). An empty
`terms` list is settlement-free peering: the absence of a fee entry is the
signal, not a zero amount. `min_pow_difficulty` is the NIP-13 work a peering
request must carry; `0` means there is no requirement. When peering is
disabled, the section is published as `{ "enabled": false }` and no scopes,
terms, or contact details are exposed.
28 changes: 28 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,34 @@ nip77:
maxSessionsPerConnection: 3
# Close a session with no NEG-MSG for this long.
sessionIdleTimeoutMs: 60000
peering:
# Relay peering advertisement, served as the `peering` section of the NIP-11
# document. This is a discovery surface: it states the feeds and terms other
# relays can read. Enabling it does not accept peers by itself.
enabled: false
# How to ask for a peering relationship: a contact URI (mailto:, https:) and/or
# the WebSocket endpoint peers should connect to. At least one is required when
# peering is enabled.
contact: ""
endpoint: ""
# NIP-13 proof-of-work bits a peering request must carry. 0 means none.
# Advertised as peering.min_pow_difficulty.
minRequestPowDifficulty: 0
# Feeds this relay is willing to serve. Each scope lists event kinds and,
# optionally, an absolute time range (unix seconds) or a rolling window.
# At least one scope is required when peering is enabled.
# Example:
# scopes:
# - kinds: [1, 3, 6, 7, 30023]
# - kinds: [[0, 9999]]
# since: 1700000000
# - kinds: [1, 6]
# windowSeconds: 3600
scopes: []
# Terms for peering, reusing the NIP-11 fee-object shape. `period` is in
# seconds. An empty list means settlement-free peering.
# Example: [{ amount: 1000, unit: msats, period: 2592000 }]
terms: []
network:
maxPayloadSize: 524288
# Uncomment only when using a trusted reverse proxy and configuring trustedProxies.
Expand Down
64 changes: 64 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,69 @@ export interface Nip56Settings {
hideActionableReports: boolean
}

export interface PeeringScope {
/**
* Event kinds this feed covers, as kinds or `[min, max]` ranges.
*/
kinds: (EventKinds | EventKindsRange)[]
/**
* Inclusive lower bound of the feed's time range as a unix timestamp in
* seconds. Omit for no lower bound.
*/
since?: number
/**
* Inclusive upper bound of the feed's time range as a unix timestamp in
* seconds. Omit for a feed that stays current.
*/
until?: number
/**
* Rolling window in seconds: the feed covers events from the last
* `windowSeconds` seconds. Mutually exclusive with `since`/`until`.
*/
windowSeconds?: number
}

export interface PeeringTerm {
/** Amount charged for one peering period, denominated in `unit`. */
amount: number
/** Unit of `amount`, for example `msats`. */
unit: string
/** Length of the peering period in seconds. */
period: number
}

export interface PeeringSettings {
/**
* Whether this relay offers peering feeds. Advertised as `peering.enabled`
* in the NIP-11 document. Advertising terms does not accept peers.
*/
enabled: boolean
/**
* Contact URI (for example `mailto:` or `https:`) for peering requests.
* At least one of `contact` or `endpoint` is required when enabled.
*/
contact?: string
/**
* WebSocket endpoint peers should connect to. At least one of `contact` or
* `endpoint` is required when enabled.
*/
endpoint?: string
/**
* NIP-13 proof-of-work bits required on peering requests. Absent or 0 means
* none. Advertised as `peering.min_pow_difficulty`.
*/
minRequestPowDifficulty?: number
/**
* Feeds this relay offers. At least one scope is required when enabled.
*/
scopes?: PeeringScope[]
/**
* Terms for peering in the NIP-11 fee-object shape (`amount`, `unit`,
* `period`). An empty list means settlement-free peering.
*/
terms?: PeeringTerm[]
}

export interface Settings {
info: Info
admin?: AdminSettings
Expand All @@ -526,5 +589,6 @@ export interface Settings {
nip56?: Nip56Settings
nip66?: Nip66Settings
nip77?: Nip77Settings
peering?: PeeringSettings
wot?: WoTSettings
}
32 changes: 31 additions & 1 deletion src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from 'express'
import { path, pathEq } from 'ramda'
import { createSettings } from '../../factories/settings-factory'
import { escapeHtml } from '../../utils/html'
import { FeeSchedule } from '../../@types/settings'
import { FeeSchedule, PeeringSettings } from '../../@types/settings'
import { DEFAULT_FILTER_LIMIT } from '../../constants/base'
import { fromBech32 } from '../../utils/transform'
import { getTemplate } from '../../utils/template-cache'
Expand Down Expand Up @@ -41,6 +41,35 @@ export const hasExplicitNostrJsonAcceptHeader = (request: Request): boolean => {
})
}

export const buildPeeringDocument = (peering: PeeringSettings | undefined) => {
// Disabled peering advertises only the fact that no feeds are offered, so a
// half-configured section never reads as an offer.
if (peering?.enabled !== true) {
return { enabled: false }
}

const contact = peering.contact?.trim()
const endpoint = peering.endpoint?.trim()

return {
enabled: true,
...(contact ? { contact } : {}),
...(endpoint ? { endpoint } : {}),
// NIP-11 convention: snake_case in the document, and 0/absent both mean
// no proof-of-work requirement.
min_pow_difficulty: peering.minRequestPowDifficulty ?? 0,
scopes: (peering.scopes ?? []).map((scope) => ({
kinds: scope.kinds,
...(scope.since !== undefined ? { since: scope.since } : {}),
...(scope.until !== undefined ? { until: scope.until } : {}),
...(scope.windowSeconds !== undefined ? { window_seconds: scope.windowSeconds } : {}),
})),
// An empty list is the settlement-free case: terms are expressed as fee
// entries, so "no fee entry" is meaningful rather than a zero amount.
terms: (peering.terms ?? []).map(({ amount, unit, period }) => ({ amount, unit, period })),
}
}

export const rootRequestHandler = (request: Request, response: Response, next: NextFunction) => {
const settings = createSettings()
const pathPrefix = getPublicPathPrefix(request, settings)
Expand Down Expand Up @@ -158,6 +187,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
{} as Record<string, { amount: number | bigint; unit: string }[]>,
)
: {},
peering: buildPeeringDocument(settings.peering),
}

response
Expand Down
Loading
Loading