innis / nostr-relay-selection
Pure-function PHP library for Nostr outbox-model publish/read routing: NIP-65/17/50/57, zero runtime dependencies.
Package info
github.com/johninnis/nostr-relay-selection-php
pkg:composer/innis/nostr-relay-selection
Requires
- php: ^8.3
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.85
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-05-19 10:41:48 UTC
README
A PHP library for routing Nostr events to and from relays. Implements outbox-model publish routing, read routing, author-set-cover, relay hint selection, NIP-65 inbox/outbox/DM list parsing, NIP-17 DM-inbox handling, and URL classification — as pure functions, with zero runtime dependencies.
Why this library?
When a client publishes a kind 1 reply, which relays should it actually send to? When it queries an author's notes, which relays will return them? When it picks a relay hint for an e tag, which one will work for the recipient? These are not trivial questions in the outbox model.
innis/nostr-relay-selection answers them as pure functions over the user's relay-list events. It does not open WebSocket connections, does not depend on a relay pool, does not depend on innis/nostr-core, and does not depend on any other Nostr library. Feed it events and a context, get back a deterministic list of relays.
Design reasoning: a spec, not an engine
The Nostr ecosystem already has several relay-selection implementations — NDK's OutboxTracker, rust-nostr's gossip crate, go-nostr's sdk hints DB, Coracle's welshman/router. They are all engines: stateful, heuristic, async, coupled to a pool and to learned data. They make pragmatic, useful tradeoffs, and none of them are deterministic.
This library makes the opposite tradeoff. It is a policy specification:
- Pure functions. Every operation is a stateless static method. Same inputs, same outputs. No I/O, no time, no randomness.
- Deterministic by construction. No
Math.random()tie-breaks (welshman), no time-decay scoring (go-nostr), noreceived_eventscounters (rust-nostr), no batch-popularity sort (NDK), no hardcoded fallback URLs. - NIP-derived behaviour only. Every routing decision is grounded in a NIP — NIP-65 for kind 10002, NIP-17 for kind 10050, NIP-57 for zap requests, NIP-50 for search relays. No empirical heuristics that drift from the spec.
- Zero runtime dependencies. Requires only PHP 8.3. Suitable for embedding in any Nostr client, relay, indexer, or back-end without dragging in transport, crypto, caches, or framework code.
- Clean Architecture, domain-only. All logic lives in the Domain layer. There is no Application or Infrastructure layer because there is nothing external to coordinate.
- Behaviour locked to a JSON corpus. The test vectors under
tests/corpus/are the spec. Any implementation in any language that passes every vector is conformant. The PHP and TypeScript ports share the corpus; a Go, Kotlin, or Rust port would too.
Engines and specs compose. This library is the policy; an engine wraps it with caching, pool state, fallbacks, scoring, or whatever else a runtime needs. The two layers stay separate so the policy stays portable and auditable.
Requirements
- PHP 8.3 or higher
No PHP extensions are required. No system libraries. No Composer dependencies.
Installation
composer require innis/nostr-relay-selection
Quick Start
All services are pure static methods. Inputs are typed context objects; outputs are typed route objects (with a branch enum + a list of RelayUrl) or lists of RelayUrl directly.
For a runnable end-to-end demonstration against live relay-list events from four real Nostr identities (loaded from tests/corpus/real-world/), see example.php:
php example.php
Constructing typed objects from raw JSON
Every type the routing services consume has a static fromRaw(mixed): ?self factory that validates the JSON-shaped input and returns null on malformed data:
use Innis\Nostr\RelaySelection\Domain\Entity\Event; use Innis\Nostr\RelaySelection\Domain\Entity\Filter; use Innis\Nostr\RelaySelection\Domain\ValueObject\Identity\PublicKey; use Innis\Nostr\RelaySelection\Domain\ValueObject\Protocol\RelayUrl; use Innis\Nostr\RelaySelection\Domain\ValueObject\Tag; $event = Event::fromRaw(json_decode($rawJson, true)); // ?Event $filter = Filter::fromRaw(['kinds' => [1], 'search' => 'hi']); // ?Filter $tag = Tag::fromRaw(['p', $pubkeyHex]); // ?Tag $pubkey = PublicKey::fromHex($hex); // ?PublicKey $relay = RelayUrl::fromString($url); // ?RelayUrl
Use these at your application's adapter boundary. The lib never returns null from happy-path routing — null from fromRaw/fromHex/fromString always means "the input you gave me was not a valid X."
Route a publish
Given an event and the user's relay-list events (kind 10002 / 10050), decide which relays to publish to. Returns a PublishRoute whose getBranch() reports the policy applied and whose getRelays() lists the targets (which may be empty, or null for the Dm branch — see below).
PublishBranch::General— NIP-65 outbox routing for kind 1/6/7/16/24/1111/9802 etc. Fans out to recipient inboxes (capped per recipient). AddsindexerRelaysfor indexed kinds (0, 3, 10002, 10050) — the events indexers likepurplepag.esharvest.PublishBranch::Dm— NIP-17 routing for kind 1059 gift-wraps. Targets recipients' kind-10050 inbox relays only. ReturnsDmwithrelays = nullif no recipient has a kind 10050 (or every DM relay is blocked) — per NIP-17, clients SHOULD NOT publish if the recipient has not signalled readiness. There is no fallback.PublishBranch::Draft— kinds 30024, 30403, 31234. Routes to caller-suppliedprivateContentRelaysif any; otherwise falls back to the user's outbox.
use Innis\Nostr\RelaySelection\Domain\Service\RoutePublishService; use Innis\Nostr\RelaySelection\Domain\ValueObject\Context\PublishContext; use Innis\Nostr\RelaySelection\Domain\ValueObject\Identity\PublicKey; use Innis\Nostr\RelaySelection\Domain\ValueObject\Route\PublishBranch; $context = new PublishContext( userPubkey: PublicKey::fromHex($userHex), relayListEvents: $cachedKind10002And10050Events, privateContentRelays: [], // caller's pre-extracted kind 10013 URLs (see NIP-37 note below) indexerRelays: [], blockedRelays: $callerBlockedRelays, // caller's pre-extracted kind 10006 URLs ); $route = RoutePublishService::route($event, $context); match ($route->getBranch()) { PublishBranch::General => /* NIP-65 outbox fan-out */, PublishBranch::Dm => /* NIP-17 DM inboxes (or null if recipient has no 10050) */, PublishBranch::Draft => /* private content relays or user outbox fallback */, }; foreach ($route->getRelays() ?? [] as $relay) { $pool->publish((string) $relay, $event); }
The lib does not implement NIP-37 itself — kind 10013's relay list is NIP-44-encrypted inside content, and decryption requires a signer + crypto, both out of scope here. Callers that want NIP-37-aware draft routing must fetch and decrypt kind 10013 themselves and pass the resulting URLs into PublishContext::$privateContentRelays.
Route a read
Given a set of filters, decide which relays to subscribe to. Returns a ReadRoute with a ReadBranch enum and a list of relays.
ReadBranch::Search— any filter with asearchfield. Unions caller-suppliedsearchRelays(from the user's kind 10007 list) withcallerRelays.ReadBranch::DmInbox— every filter is{kinds: [1059], #p: [singleRecipient]}and the recipient matches across filters. Targets the recipient's kind-10050 inbox relays.ReadBranch::General— everything else. Unions the user's relays withcallerRelays.
use Innis\Nostr\RelaySelection\Domain\Entity\Filter; use Innis\Nostr\RelaySelection\Domain\Service\RouteReadService; use Innis\Nostr\RelaySelection\Domain\ValueObject\Context\ReadContext; use Innis\Nostr\RelaySelection\Domain\ValueObject\Route\ReadBranch; $context = new ReadContext( userRelayUrls: $userOutboxRelays, callerRelays: $relaysPassedToTheQuery, filters: [new Filter(kinds: [1], pTags: null, search: null)], relayListEvents: $cachedRelayLists, blockedRelays: $callerBlockedRelays, // pre-extracted kind 10006 searchRelays: $callerSearchRelays, // pre-extracted kind 10007 ); $route = RouteReadService::route($context); match ($route->getBranch()) { ReadBranch::Search => /* subscribe to search-capable relays */, ReadBranch::DmInbox => /* subscribe to recipient's DM inboxes */, ReadBranch::General => /* subscribe to user + caller relays */, };
Filter pattern detection
RouteReadService internally calls FindFilterPatternService::classify($filters) to map a filter set to a ReadBranch (Search, DmInbox, General). The primitive is exposed so callers can inspect or branch on the pattern without invoking the full router.
use Innis\Nostr\RelaySelection\Domain\Enum\Route\ReadBranch; use Innis\Nostr\RelaySelection\Domain\Service\FindFilterPatternService; $branch = FindFilterPatternService::classify($filters); // ReadBranch::Search | ReadBranch::DmInbox | ReadBranch::General
Author read: greedy set cover
Given a list of author pubkeys, decide which outbox relays cover them. Uses greedy set-cover so two authors who share a relay are queried together; chunks each plan if the author count exceeds maxAuthorsPerFilter; falls back to caller-supplied relays for authors with no NIP-65 list.
use Innis\Nostr\RelaySelection\Domain\Service\RouteAuthorReadsService; use Innis\Nostr\RelaySelection\Domain\ValueObject\Context\AuthorReadRouteContext; $context = new AuthorReadRouteContext( authorPubkeys: $followedPubkeys, relayListEvents: $cachedRelayLists, fallbackRelays: $defaultRelays, maxAuthorsPerFilter: 200, redundancy: 3, blockedRelays: $callerBlockedRelays, ); foreach (RouteAuthorReadsService::route($context) as $route) { foreach ($route->getAuthorChunks() as $chunk) { $pool->subscribe( array_map('strval', $route->getRelays()), ['kinds' => [1], 'authors' => array_map(fn ($pk) => $pk->toHex(), $chunk)], ); } }
Pick a relay hint
For e / p / q tags, pick a single relay URL the recipient is likely to read. Prefers the intersection of the user's outbox with the target's inbox, falls back to either side's first relay, returns null if neither side has a list.
URL classification
Pure predicates on RelayUrl for use in caller-side filtering. The library does not apply these itself — they're exposed so consumers can compose filters without re-implementing host detection.
$url = RelayUrl::fromString('ws://192.168.1.11:7777'); $url->isOnion(); // false $url->isLoopback(); // false (true for localhost, 127.0.0.0/8, ::1) $url->isLocalAddr(); // true (loopback OR RFC1918 OR .local mDNS) $url->isInsecure(); // true (ws:// AND not onion)
Caller-owned lists (blocked, search, private content)
Three kinds of user-owned data are passed as pre-extracted URL lists rather than as raw events:
| Kind | NIP | Field on context | Notes |
|---|---|---|---|
| 10006 | NIP-65 | blockedRelays (on every context) |
Subtracted from every route output uniformly. |
| 10007 | NIP-50 | searchRelays (on ReadContext) |
Unioned into the Search branch alongside caller-supplied relays. |
| 10013 | NIP-37 | privateContentRelays (on PublishContext) |
Encrypted content — caller must decrypt before passing. |
For 10006 and 10007 the caller pre-extracts URLs from their cached event using RelayListExtractor::blocked($event->getTags()) or ::search($event->getTags()). For 10013, the caller does NIP-44 decryption themselves and passes the resulting URLs.
This mirrors the existing userRelayUrls pattern on ReadContext: user-owned data is the caller's responsibility to extract; library policy is to apply.
Other operations
| Service | Purpose |
|---|---|
SelectAuthorRelaysService::inbox |
Pick inbox relays for one author (kind 10002 read/both markers). |
SelectAuthorRelaysService::outbox |
Pick outbox relays for one author (kind 10002 write/both markers). |
SelectAuthorRelaysService::dm |
Pick DM relays for one author (kind 10050 relay tags). |
SelectZapRequestRelaysService |
Merge zapper and recipient inbox relays for a zap request. |
SelectRelayHintService |
Pick one relay URL hint for an e/p/q tag. |
FindFilterPatternService::classify |
Classify a filter set as Search, DmInbox, or General. |
FindFilterPatternService::sharedGiftWrapRecipient |
If every filter is {kinds: [1059], #p: [singleRecipient]} with the same recipient, return that PublicKey; otherwise null. Useful for detecting DM-target reads without invoking the full router. |
MissingRelayListPubkeysService |
For inbox-fanout events, list p-tagged pubkeys whose relay list you do not yet have cached. |
EventSelector::newestByPubkeyAndKind |
Find the newest event for a given (pubkey, kind) tuple in a heterogeneous event array. Used internally by every routing service and exposed for callers building their own cache layers. Returns ?Event. |
RelayListExtractor::inbox/outbox/dm |
Parse r and relay tags from kind 10002 / 10050 events. |
RelayListExtractor::blocked/search |
Parse relay tags from kind 10006 / 10007 events. |
RelaySetBuilder::build |
Merge any number of relay sources into one deduplicated list, preserving first-seen order. |
RelaySetBuilder::subtract |
Remove URLs in a blocklist from a relay set. |
RelayUrl::fromString |
Normalise an arbitrary URL string. Lowercases scheme and host, strips default ports and trailing slashes. Rejects non-wss(?), fragments, %20 in paths, malformed hostnames, out-of-range ports, concatenated URLs, and inputs over 200 chars. |
RelayUrl::isOnion/isLoopback/isLocalAddr/isInsecure |
Pure URL classification predicates. |
RelayUrl::equals |
Compare two RelayUrl instances for canonical-string equality. |
Event::fromRaw / Filter::fromRaw / Tag::fromRaw |
Validate and construct typed objects from JSON-shaped arrays. Return null on malformed input. |
PublicKey::fromHex / PublicKey::toHex / PublicKey::equals |
Construct from / serialise to / compare hex pubkeys. |
Routing rules
The complete policy in one place. Each rule is encoded in the source and locked by a corpus vector.
What goes in EventKind
A kind appears in EventKind if and only if the routing policy distinguishes it from arbitrary unknown kinds. Concretely, a kind belongs in the enum when at least one of these is true:
- It triggers a branch in
RoutePublishService(amatcharm or a helper predicate). - It triggers a branch in
RouteReadServiceorFindFilterPatternService. - It is parsed by
RelayListExtractor(kind 10002 / 10006 / 10007 / 10050). - It drives a dedicated service (kinds parsed from the relay-list events array).
The rule is drives a branch in RoutePublishService::route, not "has any routing rule." Two NIPs define routing rules for kinds that are nonetheless absent from EventKind, and that's deliberate:
-
Deletion(5) — NIP-09 says deletion events should publish to every relay the original event was on. The lib has no event-publication history and tracking that would require state (out of scope: no I/O, no caches). So kind 5 falls through to General → user's outbox, the best a stateless pure-policy library can offer. Adding aDeletioncase toEventKindwould imply a dedicated branch the lib cannot honestly implement. -
ZapRequest(9734) — NIP-57 routing (zapper-inbox ∪ recipient-inbox) is implemented, but as a dedicated service:SelectZapRequestRelaysService::selectoverZapRequestContext. The kind doesn't belong inEventKindbecause that enum is specifically whatRoutePublishService::routebranches on, and zap requests don't flow throughroutePublish— they're sent to an LNURL HTTP callback per NIP-57 §3, not published through the normal pool. Kind 9734 is in the routing spec; it just enters via a different door.
Pure vocabulary-only constants — Report (1984), LiveActivity (30311), Job (5000-5999), etc. — never enter the routing spec at all. A future kind registry package (or innis/nostr-core) is the right home for those names. (ProfileMetadata (0) and FollowList (3) earned their place by being indexed kinds; the rule remains "drives a routing decision, or out.")
Publish branches
RoutePublishService::route($event, $context) dispatches on event kind into one of three branches. Every output also has the user's blockedRelays subtracted.
| Branch | Triggering kinds | Output relays |
|---|---|---|
Dm |
1059 (GiftWrap) |
Per recipient (p tag), the recipient's newest kind-10050 inbox relays. Empty if the recipient has no kind 10050 (per NIP-17 "shouldn't try"). |
Draft |
30024 (LongformDraft), 30403 (ClassifiedListingDraft), 31234 (DraftEvent) |
privateContentRelays if non-empty, otherwise user's outbox (from newest kind 10002, write/both markers). |
General |
Everything else | User's outbox, plus — for isInboxFanout kinds — recipient inbox fanout (capped per recipient), plus — for isIndexed kinds — indexerRelays. |
isInboxFanout includes: 1 (ShortNote), 6 (Repost), 7 (Reaction), 16 (GenericRepost), 24 (PublicMessage), 1111 (Comment), 9802 (Highlight). For these kinds, every p-tagged recipient's newest kind-10002 inbox (read/both markers; unmarked entries count as both) is unioned in. If the recipient has no usable inbox — either because kind 10002 is absent OR because the cached kind 10002 has no read/both entries (write-only) — the lib falls back to the position-[2] relay hint on the p tag, if any. Per-recipient cap defaults to 3 (PublishContext::DEFAULT_PER_RECIPIENT_CAP).
isIndexed includes: 0 (ProfileMetadata), 3 (FollowList), 10002 (RelayList), 10050 (DmRelayList). When publishing one of these, indexerRelays are unioned into the General output so well-known indexers (purplepag.es, user.kindpag.es, relay.nos.social, etc.) see the updated event. The set matches welshman's INDEXED_KINDS.
Read branches
RouteReadService::route($context) dispatches on filter shape. Pattern detection is also exposed as a primitive via FindFilterPatternService::classify($filters), which returns ReadBranch directly. Every output has blockedRelays subtracted.
| Branch | Triggering filter shape | Output relays |
|---|---|---|
Search |
Any filter has a non-null search field |
searchRelays ∪ callerRelays. |
DmInbox |
Every filter is exactly {kinds: [1059], #p: [singleRecipient]} and the recipient is the same across filters |
Recipient's newest kind-10050 inbox relays, or null if none are available (see below). |
General |
Anything else (including no filters) | userRelayUrls ∪ callerRelays. |
DM branches return null with no fallback
The DM branches — ReadBranch::DmInbox and PublishBranch::Dm — are the only branches whose relays can be null. Both return null when the recipient has no cached kind-10050 event, when the kind-10050 event extracts to no relays, or when blocking removes every DM relay the recipient declared. There is no fallback to callerRelays, userRelayUrls, or any other source — a gift-wrap publish or subscription cannot quietly redirect to relays the recipient has not authorised without leaking metadata about who the caller is talking to. A null result means "the caller cannot honour this DM; do not act."
The other branches never return null; they may return an empty array if their inputs are all empty (e.g. user has no outbox and the caller supplied no relays), which signals caller misconfiguration rather than a routing refusal:
relays value |
Meaning |
|---|---|
null |
Branch is Dm / DmInbox and no DM-relay route exists. By design. Do not act. |
[] |
Branch is non-DM and the inputs produced nothing. Caller misconfiguration. |
| non-empty | Use these relays. |
Unknown kinds
Any kind not specifically named in the policy routes as PublishBranch::General with no recipient fanout and no indexer relays — i.e. to the user's outbox only. This is deliberate: the spec-derived default for an unrecognised kind is "publish to the author's own outbox, nothing more."
Two paths to change that:
- Adapter layer (preferred for app-specific behaviour). If your client has its own routing intuition for a new or experimental kind, handle it in the adapter that wraps this library. Inspect
$event->getKind(), branch as you wish, and feed your selected relays directly into your pool. The library returns its General-branch answer; your adapter overlays your policy on top. This keeps the spec library small and stable. - Library (only when the rule belongs in the spec). If a NIP defines routing for a new kind and the lib should encode that NIP-derived rule for everyone, add a case to
EventKindand an arm to the relevant helper or branch. This adds a corpus vector and a binding decision across every port. Reserve it for behaviour that's a property of the protocol, not of your app.
Blocklist application
blockedRelays is subtracted from every routing output uniformly. The list is the caller's responsibility to pre-extract from kind 10006 events (use RelayListExtractor::blocked($event->getTags())). The library never connects to relays, so "blocked" here means "filtered out of routing outputs"; the engine layer above the library should also refuse to open connections to these URLs.
Search relays
searchRelays is the caller's pre-extracted list from their kind 10007 event. It is unioned (alongside callerRelays) into the Search branch only. It is not consulted for General or DmInbox reads.
Architecture
src/Domain/
Entity/
Event.php Minimal event carrier (kind, pubkey, created_at, tags)
Filter.php Minimal filter carrier (kinds, #p, search)
Enum/
EventKind.php Backed enum of well-known kinds + static isInboxFanout / isDraft / isIndexed
Route/
PublishBranch.php (General | Dm | Draft)
ReadBranch.php (Search | DmInbox | General)
Service/ Pure stateless services
RelayListExtractor.php
RelaySetBuilder.php
EventSelector.php
RoutePublishService.php
RouteReadService.php
RouteAuthorReadsService.php
SelectAuthorRelaysService.php
SelectZapRequestRelaysService.php
SelectRelayHintService.php
FindFilterPatternService.php
MissingRelayListPubkeysService.php
ValueObject/
Identity/PublicKey.php
Protocol/RelayUrl.php (with isOnion/isLoopback/isLocalAddr/isInsecure)
Tag.php Single-tag wrapper (value-indexed string access + fromRaw factory)
Context/ Typed inputs (PublishContext, ReadContext, etc.)
Route/ Typed outputs (PublishRoute, ReadRoute, AuthorReadRoute, enums)
There is no Application layer because no infrastructure ports are needed. There is no Infrastructure layer because there are no adapters. Routing is pure protocol logic.
Relationship to innis/nostr-core
innis/nostr-relay-selection deliberately does not depend on innis/nostr-core. The two libraries are independent and can be used together or separately.
PublicKey, RelayUrl, Event, and Filter are re-declared here in minimal form because re-declaring four small value objects is preferable to forcing every consumer of relay selection to also pull in nostr-core's full cryptographic stack. The duplication is marked in a header comment on each duplicated file. If you are already using nostr-core, convert at the boundary (PublicKey::fromHex($core->toHex())).
What this library does NOT do
Deliberate omissions. These belong in the engine layer wrapping the lib, not in the lib itself.
- No empirical scoring. No
received_eventscounters, no time-decay, no popularity sort. The lib treats all spec-derived relays as equally valid; ordering follows the NIP rules and input order only. - No fetch tracking / no learning. The lib does not know what relays you've connected to, succeeded with, or failed against. State is the caller's responsibility.
- No caching. Every call re-reads the events you pass in. Cache them yourself if you need to.
- No randomness, no tie-breaks. Two calls with the same inputs return the same outputs in the same order. Always.
- No hardcoded fallback URLs. If your routing returns empty, the caller decides what to do. The lib will never silently inject
wss://relay.damus.ioor any other default. - No pool-state awareness. Whether a relay is currently connected is invisible to the lib.
- No transport, no crypto, no I/O. The lib has no
connect(), nopublish(), nosign(), nodecrypt(). Pure data in, pure data out.
Testing
composer test # Unit + Compliance + PHPStan (ship gate) composer test-unit # Unit suite only composer analyse # PHPStan level 9 composer fix-style # php-cs-fixer
The compliance suite loads JSON test vectors under tests/corpus/. The corpus is the spec — any divergence between implementations is a test failure.
The corpus includes signed events imported verbatim from rust-nostr/nostr's gossip test suite under tests/corpus/external-fixtures/rust-nostr/. Each fixture carries a source field naming the upstream test it came from. The imported events are unmodified; our policy outputs are independently derived from the NIPs and may differ from rust-nostr's. Sharing fixture inputs across implementations makes any such divergence inspectable.
Anti-patterns
- Calling routing services directly from many places in your app. Wrap them in a single adapter so policy lives in one place. If app-specific behaviour (defaults, fallbacks, pool state, home-relay ordering) leaks into the routing call sites, you'll end up with N divergent policy stacks instead of one.
- Adding app-specific logic to this lib. If your change needs to know about pool state, default relays, or the home relay, it belongs in the adapter, not here. The lib must remain pure and portable.
- Adding a routing service without a corresponding test vector. The corpus is the spec. Add a vector to
tests/corpus/*.jsonand the harness picks it up automatically. - Putting a new kind into a relay set at the call site. Add a case to
EventKindand extend the appropriateisInboxFanout/isDraft/isIndexedpredicate here, so every caller's routing changes consistently.
License
MIT License. See LICENSE file for details.