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.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- innis/coding-standards: ^0.2
- phpstan/phpstan: ~2.3.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ~2.7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Deterministic outbox-model relay routing for Nostr in PHP: publish routing, read routing, author set cover, relay hints, zap-request relays and URL classification, as pure functions with zero runtime dependencies. A routing policy locked to a shared JSON corpus, not an engine.
Why this library?
When a client publishes a reply, which relays should it send to? When it reads an author's notes, which relays hold them? Which relay hint will work for the person a tag points at? This library answers those questions from the relay lists people publish (NIP-65, NIP-17, NIP-51), deterministically: the same inputs always give the same relays in the same order. It opens no connections, keeps no state and ships no default relays; your relay pool and your fallbacks wrap it. Why it is built that way is recorded in ADR-0006.
The TypeScript port, @innis/nostr-relay-selection, implements the same policy against the same corpus.
Requirements
- PHP 8.4 or higher
- No extensions, system libraries or Composer dependencies
Installation
composer require innis/nostr-relay-selection
Quick start
use Innis\Nostr\RelaySelection\Domain\Collection\EventCollection; use Innis\Nostr\RelaySelection\Domain\Collection\RelaySet; use Innis\Nostr\RelaySelection\Domain\Failure\NoDmRelaysFailure; use Innis\Nostr\RelaySelection\Domain\Service\PublishRouter; use Innis\Nostr\RelaySelection\Domain\ValueObject\Protocol\Event; use Innis\Nostr\RelaySelection\Domain\ValueObject\Routing\RelayDirectory; $relayLists = new EventCollection(array_filter(array_map(Event::tryFromRaw(...), $rawRelayListArrays))); $directory = RelayDirectory::fromEvents($relayLists, RelaySet::fromStrings(['wss://spam.example.com'])); $note = Event::tryFromRaw($rawNoteArray); if (null !== $note) { $route = PublishRouter::route($note, $directory); if (!$route instanceof NoDmRelaysFailure) { foreach ($route->getRelays() as $relay) { $pool->publish((string) $relay, $note); } } }
examples/route-relays.php is a self-contained, runnable walk through every operation with three synthetic identities:
php examples/route-relays.php
Types and validation
Every input type has a parser for untrusted, JSON-shaped data that returns null when the input is malformed:
$event = Event::tryFromRaw($array); // ?Event: id, kind, pubkey, created_at, tags $filter = Filter::tryFromRaw($array); // ?Filter: kinds, #p, search (other fields ignored) $tag = Tag::tryFromRaw(['p', $hex]); // ?Tag $pubkey = PublicKey::tryFromHex($hex); // ?PublicKey $id = EventId::tryFromHex($hex); // ?EventId $relay = RelayUrl::tryFromString($url); // ?RelayUrl $relays = RelaySet::fromStrings($urls); // RelaySet, malformed entries dropped
Collections are typed and immutable: EventCollection, FilterCollection, PublicKeyCollection, TagCollection and RelaySet reject an element of the wrong type with InvalidArgumentException. RelaySet also deduplicates by canonical URL on construction and offers union, without, take, first and toStrings.
Event carries the five fields routing reads. The id is used only to break a created_at tie between two relay lists: the lowest id wins, as NIP-01 specifies. Event::tryFromRaw refuses, as the nostr-core libraries do, an event whose kind lies outside 0 to 65535, whose created_at is negative, or which carries a tag with no elements, [], and the Event and Tag constructors throw InvalidArgumentException for the same values. A tag whose name is the empty string is a tag like any other (ADR-0004).
The relay directory
Every routing operation reads relay lists through one RelayDirectory, built once from the relay-list events you hold and your blocklist:
$directory = RelayDirectory::fromEvents($relayListEvents, $blockedRelays); $directory->relaysOf($pubkey, RelayRole::Outbox); // RelaySet $directory->permitted($callerRelays, $extraRelays); // union of the sets, minus blocked relays $directory->getBlocked(); // the blocklist
-
For each (pubkey, kind) the directory keeps the newest event: the greatest
created_at, then the lowest id. The result does not depend on the order of the events. -
A
RelayRolenames what a list is for and owns the kind it is read from:Role Kind Tags read RelayRole::Inbox10002 (NIP-65) rtags not markedwrite(unmarked,read,bothor any other marker), a relay named twice being the union (ADR-0016)RelayRole::Outbox10002 (NIP-65) rtags not markedread(unmarked,write,bothor any other marker), a relay named twice being the unionRelayRole::Dm10050 (NIP-17) relaytagsRelayRole::Search10007 (NIP-51) relaytagsRelayRole::Blocked10006 (NIP-51) relaytags -
Every relay set the directory returns is deduplicated and has the blocklist removed, so no routing operation ever chooses a blocked relay.
The blocklist is passed in rather than read from a kind 10006 event because NIP-51 lets entries live in encrypted content, which this library cannot decrypt. Read the public entries with RelayRole::Blocked->extract($event->getTags()), add any you decrypted, and pass the result.
Route a publish
PublishRouter::route($event, $directory, $policy) returns a PublishRoute (getBranch(), getRelays()) or, for a gift wrap with nowhere to go, NoDmRelaysFailure::NoDmRelays. The branches are tried in this order:
| Branch | When | Relays |
|---|---|---|
PublishBranch::Dm |
kind 1059 or 21059 (NIP-59 gift wrap, stored or ephemeral) | Every p-tagged recipient's kind 10050 relays. None left: NoDmRelaysFailure (NIP-17: do not publish; there is no fallback). |
PublishBranch::Draft |
kinds 30024 and 31234 (NIP-23 draft article, NIP-37 draft wrap) | privateContentRelays, or the author's outbox when none remains after blocking. Kind 30403, a NIP-99 classified listing saved as a draft or inactive, is public and takes the branch its tags give it. |
PublishBranch::Group |
an h tag with a non-empty group id (NIP-29) |
The h tags' relay hint and groupRelays; no outbox or inbox fan-out. The h tags are one claim: tags naming the same group id name that group whatever hints they carry, the hint is the one relay every tag with a relay URL hint names, compared in canonical form, and hints that disagree are no hint. Tags naming different group ids name no group, and the route has no relays. When none remains after blocking, the route has no relays either: a group event never falls through to General (ADR-0011). |
PublishBranch::General |
everything else | The author's outbox; plus every inbox relay of each p-tagged recipient (NIP-65), or, as a best-effort fallback, the p tag's relay hint when the recipient has no inbox; plus indexerRelays for indexed kinds (0, 3, 10002, 10050). perRecipientCap limits the inbox relays per recipient; by default (PerRecipientCap::all()) there is no limit, and a kind 24 public message (NIP-A4) always goes to every inbox relay of each receiver. A kind whose p tags are data, not mentions, has no recipients and goes to the author's outbox (and indexers) only: kind 3 (NIP-02 follow list), 1984 (NIP-56 report) and the NIP-51 lists and sets of people, 10000, 10017, 10020, 10054, 10064, 10101, 30000, 30007, 39089 and 39092 (EventKind::isPubkeyData). Every other kind fans out (ADR-0018). |
The outbox is always the event author's. Blocked relays are removed before any choice: a recipient's inbox is deduplicated and cleared of blocked relays before the cap is taken, a blocked hint is ignored, a draft whose relays are all blocked falls back to the author's outbox, and a group event whose relays are all blocked keeps the group route with none (ADR-0010). DM and draft branches come before the group branch, so private content never reaches a group relay (ADR-0011), and no DM, draft or group message fans out to a tagged user's inbox.
NIP-65 also asks that the author's kind 10002 be sent to every relay the event was published to. That is your job: publish the author's relay list event to the same route.
$route = PublishRouter::route($event, $directory, new PublishPolicy( privateContentRelays: $decryptedKind10013Relays, indexerRelays: RelaySet::fromStrings(['wss://purplepag.es']), groupRelays: $relaysHostingThisGroup, perRecipientCap: new PerRecipientCap(3), // optional; default PerRecipientCap::all() )); $strategy = $route instanceof NoDmRelaysFailure ? 'do not publish' : match ($route->getBranch()) { PublishBranch::General => 'NIP-65 outbox and inbox fan-out', PublishBranch::Dm => 'NIP-17 DM relays', PublishBranch::Draft => 'private content relays', PublishBranch::Group => 'NIP-29 group relays', };
A non-DM route may carry an empty RelaySet when your inputs name no relay (the author has no outbox and you supplied nothing, or a group event names no relay of its group); what to fall back to is your decision.
Route a read
ReadRouter::route($filters, $directory, new ReadPolicy($userPubkey, $callerRelays)) returns a ReadRoute or NoDmRelaysFailure:
| Branch | When | Relays |
|---|---|---|
ReadBranch::Search |
any filter has a non-empty search |
The user's kind 10007 search relays, then the caller relays. |
ReadBranch::DmInbox |
every filter asks only for kinds 1059 and/or 21059 with exactly one #p, the same recipient in each |
The recipient's kind 10050 relays. None left: NoDmRelaysFailure. |
ReadBranch::General |
anything else, including no filters | The inbox relays of every user a #p names (NIP-65: events about a user are read from that user's read relays), then the user's inbox and outbox, then the caller relays. The user's own relays are read only when no filter has a #p, a filter has none, or a tagged user has no inbox left after blocking (ADR-0019). |
FilterPatternClassifier::classify($filters) exposes the classification on its own as a FilterPattern, with the recipient available from getDmRecipient().
Read many authors
AuthorReadRouter::route($authors, $directory, $policy) plans which outbox relays to read a set of authors from. Greedy set cover groups authors who share a relay; each author is read from up to the redundancy target; each route's authors are chunked. Authors with no usable outbox (no kind 10002, no write entries, or all blocked) are read from the fallback relays in a final route, which is omitted when no fallback relay remains. No route ever has an empty relay set.
$routes = AuthorReadRouter::route($followed, $directory, new AuthorReadPolicy( fallbackRelays: $defaultRelays, maxAuthorsPerFilter: new PositiveCount(200), redundancy: new Redundancy(3), // or Redundancy::all() )); foreach ($routes as $route) { foreach ($route->getAuthorChunks() as $chunk) { $pool->subscribe($route->getRelays()->toStrings(), ['kinds' => [1], 'authors' => array_map(fn ($pk) => $pk->toHex(), $chunk->toArray())]); } }
PositiveCount, PerRecipientCap and Redundancy throw InvalidArgumentException for a count below one (ADR-0013).
Other operations
| Operation | Purpose |
|---|---|
RelayHintSelector::select($userPubkey, $target, $directory) |
One relay hint for a tag, else null: a relay where the target's events are found (ADR-0020). $target is new RelayHintTarget($pubkey, $seenOn = null): $pubkey is the referenced event's author for an e / q / a tag and the tagged user for a p tag; $seenOn is a relay you received the target from (the event, or an event by the tagged user). The hint is $seenOn unless blocked, else the target's first outbox relay, else the user's first inbox relay. |
ZapRequestRelaySelector::select($zapperPubkey, $recipientPubkey, $directory) |
NIP-57: the zapper's inbox relays, then the recipient's. |
RecipientsWithoutInboxFinder::find($event, $directory) |
The p-tagged recipients publish routing cannot reach through an inbox (no kind 10002, no read entries, or all blocked), for which PublishBranch::General falls back to the p-tag hint. Useful for fetching missing relay lists before publishing. Empty for gift wrap and draft kinds, for a group event (an h tag with a non-empty group id) and for kinds whose p tags are data, none of which fans out to an inbox. |
RecipientExtractor::fromEvent($event) |
The event's p-tagged recipients with their relay hints, first occurrence of each pubkey; none for a kind whose p tags are data. |
GroupClaimExtractor::fromEvent($event) |
The event's NIP-29 group as its h tags state it, read as one claim. null when no h tag names a non-empty group id; otherwise a GroupClaim, whose namesGroup() is false when the tags name different group ids and whose getHint() is the one relay every tag carrying a relay URL hint names, or null when none does or they disagree. Publish routing and RecipientsWithoutInboxFinder tell a group event by it (ADR-0011). |
RelayRole::extract($tags) / RelayRole::kind() |
Read one role's relays from a relay-list event's tags; the kind a role is read from. |
RelayUrl::isOnion / isLoopback / isLocalAddr / isInsecure |
URL predicates for your own filtering; routing never applies them. Address ranges match IPv4 dotted quads only, so wss://10.example.com is not local; no relay URL holds an IPv6 literal (ADR-0017). |
EventKind::isGiftWrap / isDraft / isIndexed / isPubkeyData |
The kind groupings routing branches on. EventKind names only the kinds routing distinguishes (ADR-0014). |
Caller-owned lists
Three lists can be partly or wholly encrypted, so you pass their relays in rather than the library reading events:
| Kind | NIP | Where it goes |
|---|---|---|
| 10006 blocked relays | NIP-51 | RelayDirectory::fromEvents($events, $blocked); removed from everything. |
| 10007 search relays | NIP-51 | Public entries are read from the directory for the search branch; add decrypted entries to the read policy's caller relays. |
| 10013 private content relays | NIP-37 | The privateContentRelays argument of PublishPolicy, for drafts. |
Upgrading from 0.4
0.5 routes group events, kind 30403 and kind 24 as the shared Nostr records decide, and refuses more malformed events:
- A NIP-29 group event never falls through to
PublishBranch::General. An event whosehtag names a group routes asPublishBranch::Groupeven when no relay remains, from the tag's hint orgroupRelays, after blocking, and the route'sRelaySetis then empty: resolve the group's relay, or do not publish (ADR-0011). htags are read as one claim instead of first-wins. Tags naming the same group id name that group whatever hints they carry; the hint is the one relay every tag with a relay URL hint names, in canonical form, and hints that disagree are no hint, leavinggroupRelays. Tags naming different group ids name no group and route asPublishBranch::Groupwith no relays (ADR-0011).RecipientsWithoutInboxFinderreports no recipients for a group event (anhtag with a non-empty group id), which never fans out to an inbox. It tells a group event byGroupClaimExtractor::fromEvent, new, the readerPublishRouteruses (ADR-0011).- Kind 30403, the NIP-99 classified listing draft, is a public listing, no longer a draft: it routes as
PublishBranch::General, orPublishBranch::Groupwhen anhtag names its group, never toprivateContentRelays, andRecipientsWithoutInboxFinderreports its recipients.EventKind::ClassifiedListingDraftis removed, soEventKind::isDraftno longer covers it (ADR-0014). - A kind 24 public message (NIP-A4) goes to every inbox relay of each receiver whatever
PerRecipientCapyou set.EventKind::PublicMessage(24) is named for it; amatchoverEventKindneeds the new case (ADR-0018). Event::tryFromRawreturnsnullfor akindoutside 0 to 65535 and a negativecreated_at, andEvent::tryFromRawandTag::tryFromRawfor a tag with no elements,[], as the nostr-core libraries refuse them.new Event(...)andnew Tag(...)throwInvalidArgumentExceptionfor the same values (ADR-0004).
Upgrading from 0.3
0.4 renames one kind:
EventKind::DraftEvent(31234) becomesDraftWrap, the name both nostr-core libraries give it (ADR-0014). It routes as before.
Upgrading from 0.2
0.3 replaces the context objects with the directory and small policy objects:
- Build
RelayDirectory::fromEvents($events, $blocked)once and pass it to every call.PublishRouter::route($event, $context)becomesroute($event, $directory, $policy);ReadRouter::route($context)becomesroute($filters, $directory, $policy);AuthorReadRouter::route($context)becomesroute($authors, $directory, $policy);RelayHintSelector::select($context)becomesselect($userPubkey, $target, $directory)(see below) andZapRequestRelaySelectortakes pubkeys then the directory;MissingRelayListFinderbecomesRecipientsWithoutInboxFinder. AuthorRelaySelector,EventSelector,RelayListExtractorandRelaySetBuilderare gone: use$directory->relaysOf(),RelayRole::extract()andRelaySet.- Routes never have
nullrelays. A refused DM isNoDmRelaysFailure::NoDmRelays; test with$route instanceof NoDmRelaysFailure.AuthorReadRouterreturns anAuthorReadRouteCollection. Event,FilterandTagmoved toDomain\ValueObject\Protocol;Eventrequires an id; lists are typed collections;PublicKeyhas no__toString(usetoHex());Filter::getSearch()is gone;EventKindpredicates are enum methods.- Counts are
PositiveCount/PerRecipientCap/Redundancyvalue objects;nullredundancy becomesRedundancy::all(). - The publish outbox is the event author's (
$event->getPubkey()), not a caller-supplied user key:PublishContext::$userPubkeyis gone (ADR-0018). The read branch derives the user's relays from the directory. - Publish routing gains NIP-29 group routing: an
h-tagged event whose group relays (the tag's relay hint andPublishPolicy's group relays) are not all blocked routes asPublishBranch::Groupto those relays only, after the DM and draft branches, so anh-tagged gift wrap or draft is never sent to a group relay (ADR-0011). AmatchoverPublishBranchneeds the new case. - Kinds take nostr-core's names:
EventKind::ProfileMetadatabecomesMetadata,BlockedRelayListBlockedRelaysList,SearchRelayListSearchRelaysListandLongformDraftLongformContentDraft(ADR-0014). - An
rtag with a marker other thanreadorwritenow counts as both instead of being dropped, and a relay named by severalrtags is the union of them (ADR-0016). - Kind 21059 (the NIP-59 ephemeral gift wrap) is routed like kind 1059: published to the recipients' kind 10050 relays or refused, and a read asking only for gift-wrap kinds with one
#pis aReadBranch::DmInboxread.FilterPatternClassifier::classifyreturns aFilterPatterninstead of aReadBranch, andsharedGiftWrapRecipientis gone (usegetDmRecipient()). - Blocked relays are removed before any choice is made, so a blocklist can change the branch, not only the relays: a recipient's inbox is deduplicated and cleared of blocked relays before the cap, a recipient whose inbox is all blocked falls back to the
p-tag hint, and a draft or group whose relays are all blocked falls back (ADR-0010).AuthorReadRouteromits the fallback route when no fallback relay remains instead of returning one with no relays (ADR-0012). RecipientsWithoutInboxFinderreports every recipient publish routing cannot reach through an inbox (no kind 10002, a list with no read entries, or every inbox relay blocked), whereMissingRelayListFinderreported only those with no kind 10002.RelayUrl::isLoopbackandisLocalAddrmatch address ranges on dotted-quad IPv4 hosts only: a hostname such as127.example.comor10.example.comis no longer loopback or local.Event::tryFromRaw,Filter::tryFromRawandTag::tryFromRawrefuse an array where the protocol has the other JSON type: a filter given as a non-empty list,kinds,#p,tagsor a tag given as a keyed array.- Publish fan-out follows NIP-65: on
PublishBranch::Generalan event of any kind that mentions users goes to every inbox relay of eachp-tagged recipient. The kinds whoseptags are data, not mentions, still go to the author's outbox only, as they did in 0.2 whereEventKind::isInboxFanoutdid not list them: kind 3, kind 1984 and the NIP-51 lists and sets of people (10000, 10017, 10020, 10054, 10064, 10101, 30000, 30007, 39089, 39092).EventKind::isPubkeyDatanames them, with the new casesReporting,MuteList,GitAuthorsList,MediaFollowsList,FavouritePodcastsList,AuthoredPodcastsList,GoodWikiAuthorsList,FollowSet,KindMuteSet,StarterPackandMediaStarterPack; amatchoverEventKindneeds them. The per-recipient cap isPublishPolicy'sPerRecipientCap, defaulting toPerRecipientCap::all()wherePublishContextdefaulted to 3 (passnew PerRecipientCap(3)to keep a cap), andPublishContext::DEFAULT_PER_RECIPIENT_CAPis gone.EventKind::isInboxFanoutand the cases it alone needed (ShortNote,Repost,Reaction,GenericRepost,PublicMessage,Comment,Highlight) are removed; take kinds frominnis/nostr-core.RecipientsWithoutInboxFindernow reports recipients for every kind except gift wraps, drafts and the kinds whoseptags are data (ADR-0018). - A
ReadBranch::Generalread whose filters name users in#preads those users' inbox relays, and the user's own relays only when a filter has no#por a tagged user has no inbox (ADR-0019). - Sending the author's kind 10002 to the relays an event went to (NIP-65) is the caller's job.
RelayUrl::isLoopbackno longer names::1: no relay URL can hold an IPv6 literal, so it never matched (ADR-0017).RelayHintSelector::select's$targetisnew RelayHintTarget($pubkey, $seenOn = null), and every hint names a relay where its target's events are found:$seenOn(a relay you received the event, or an event by the tagged user, from), then the target's outbox, then the user's inbox, where 0.2 took the user's outbox relay in the target's inbox, then the target's inbox, then the user's outbox.$pubkeyis the referenced event's author for ane/q/atag and the tagged user for aptag (ADR-0020).
Testing
composer test # PHPUnit (Unit, Compliance, Acceptance) and PHPStan level 9 composer test-unit # Unit suite only composer analyse # PHPStan level 9 composer check-style # php-cs-fixer, dry run composer check-rector # Rector, dry run composer check-path-lengths # no tracked path component over 95 characters composer fix-style # php-cs-fixer
The corpus under tests/corpus/ is the specification. It is a byte-identical copy of the TypeScript repository's corpus, and a pinned digest fails the build if the two drift (ADR-0007). Its README describes every vector format.
Architecture decisions
Design rationale, including the choices that read like smells until you know why (why the protocol types are re-declared instead of depending on innis/nostr-core, why the routing operations are static), lives in docs/adr/. Read it before changing the code.
License
MIT License. See LICENSE file for details.