13 KiB
Backend audit: relays, sync and NIP-65
Status: audit of the current code, done before implementing
docs/event-fetching-strategy.md. Where this document and the first draft of
that proposal disagree, this document is authoritative.
Scope and method
Crates read for relay and network behaviour:
signed_state:backend,repo,repos,inbox,profile,checkouts,local_repos,refresh,git_store.signed_nostr:backend,signer,update.signed_core:filters,deletions,model,state,status,inbox.- Relay touchpoints of
signed_git,settings,paths,utils.
Cross-checked against:
- rust-nostr at the revision from
Cargo.lock,b230cecf9dbb38e0228e6fff4544ed9d261326fc. Allnostr*crates (nostr,nostr-database,nostr-gossip,nostr-gossip-memory,nostr-lmdb,nostr-sdk) resolve to that single revision. The local checkout is~/.cargo/git/checkouts/nostr-619b808bb247a9ed/b230cec. - GitWorkshop, cloned with
ngitfromnostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/gitworkshopat revision420c0c3.
Paths below are relative to those checkouts unless prefixed with
crates/, which means this repository.
1. Verified SDK behaviour
Target selection
nostr-sdk/src/client/api/req_target.rs:ReqTarget::autowraps a bare filter list;ReqTarget::manualwraps a relay map.From<HashMap<T, Vec<Filter>>>(L91) makes an explicit map a manual request.nostr-sdk/src/client/api/util.rs::build_targets:- Auto + gossip configured ->
gossip_break_down_filters. - Manual -> the map is used as-is, gossip is skipped.
- Auto + gossip configured ->
nostr-sdk/src/client/api/subscribe.rsandapi/sync.rsboth go through these rules.client.sync(filter)with no.with(...)uses gossip;client.sync(filter).with(urls)does not.pool.syncerrors withrelay not foundwhen a targeted relay has not been added to the pool. Targets are not implicitly added.
Gossip breakdown
nostr-sdk/src/client/gossip/updater.rs::gossip_break_down_filter(L412) extracts pubkeys viaFilter::extract_public_keys(nostr/src/filter/mod.rsL591): onlyauthorsand the lowercase#ptag. It first callsensure_gossip_public_keys_fresh, then breaks the filter down, then adds every resolved relay to the pool withRelayCapabilities::GOSSIP.- Freshness (
updater.rsL381-L410, L164-L185) negentropy-syncs kind10002for the extracted pubkeys over the pool'sDISCOVERY | READrelays. This is the only automatic way the gossip store learns kind10002. nostr-sdk/src/client/gossip/resolver.rs::break_down_filter(L93):authorsonly -> each author's write relays, plus hint and most-received relays.#ponly -> each pubkey's read relays, plus hint and most-received.- both -> the union of read and write relays, keeping the filter unchanged.
- neither (
Other), or no relay found (Orphan) -> the pool's read relays (read_relay_urls).
relay/capabilities.rs:GOSSIPis its own bit.pool.read_relay_urls/write_relay_urls/relay_urls_with_any_capuse a raw bit test (filter_relays_with_any_cap), so GOSSIP-only relays are excluded from broadcast and from theOther/Orphanfallback. Per-relay send checks usecan_read/can_write, which do include GOSSIP, so gossip-resolved targets work when named explicitly.GossipConfig::default()(builder.rsL30-L120): limits read 3, write 3, hints 1, most-received 1, NIP-17 3 per user;background_refreshon by default, disabled by.no_background_refresh().NostrGossipMemoryreads only the first 7 entries of a kind10002(MAX_NIP65_SIZE) and stops tracking new hint/most-received relays once a pubkey is nearMAX_RELAYS_PER_PK(7).get_best_relays(pk, selection, allowed)(public trait,gossip/nostr-gossip/src/lib.rs) reads whatever is in the store, sorted by received-event count then recency. It does not fetch kind10002itself. The store is primed by the freshness step of an Auto request (or by any kind10002that arrives for another reason), and marks a key outdated 24 h after its last fetch attempt.
Publishing
client.send_event(event)with no policy and gossip configured targets NIP-65: it triggers the same freshness, then sends to the author's outbox and everyp-tagged pubkey's inbox (send_event.rs::gossip_prepare_urls).client.send_event(event).broadcast()overrides this to the pool's write relays only (send_event.rsL428-L430).signedusesbroadcast()everywhere.
Notifications
- Every message from a relay passes
relay/inner.rs::handle_event_msg, which saves new events and emitsClientNotification::Event. This includes events received by a negentropy sync (the sync's down subscription is registered as an auto-closing subscription, and the events travel the normal message path). Events already in the database do not re-notify. - The comment in
crates/signed_state/src/profile.rsclaiming synced events produce noNostrUpdateis inaccurate for this revision. The re-read after a sync is harmless, but the comment should not be relied on.
2. What signed actually does today
Every fetch path is manual. There is no Auto request anywhere in the
workspace, so gossip_break_down_filter and ensure_gossip_public_keys_fresh
never run. The gossip store is configured and passively fed
(handle_event_msg calls gossip.process for every received event), but it
is never consulted for targeting.
| Call site | Request | Target relays | Target kind |
|---|---|---|---|
Backend::bootstrap |
add_relay + connect |
BOOTSTRAP_RELAYS (READ|WRITE), INDEXER_RELAYS (DISCOVERY) |
n/a |
Backend::subscribe_bootstrap -> subscribe_bootstrap_only |
client.subscribe(HashMap<&str, Vec<Filter>>), ExitOnEOSE, 10 s timeout |
BOOTSTRAP_RELAYS |
manual |
Backend::sync_bootstraps -> sync_bootstrap_only |
client.sync(filter).with(BOOTSTRAP_RELAYS) |
BOOTSTRAP_RELAYS |
manual |
Backend::connect_repo_relays |
add_relay().and_connect() then client.sync(filter).with(relays) |
announcement relays tag |
manual |
RepoListStore::subscribe_remote |
sync_bootstraps(all_announcements, all_states, deletions) |
BOOTSTRAP_RELAYS |
manual |
RepoListStore::sync_own_repo_states |
connect_repo_relays(state(addr)) |
own repos' announced relays | manual |
RepoStore::subscribe_remote |
subscribe_bootstrap(repo_filters) |
BOOTSTRAP_RELAYS |
manual |
RepoStore::connect_announced_relays |
connect_repo_relays(repo_filters) |
announced relays | manual |
RepoStore::run_refresh root follow-ups |
subscribe_bootstrap(root_filters) + connect_repo_relays(root_filters) |
bootstrap + announced relays | manual |
Backend::sync_inbox |
subscribe_bootstrap(notifications, authored_activity) + connect_repo_relays over own announcements |
bootstrap + own repos' relays | manual |
Backend::bootstrap_user |
sync_bootstrap_only(grasp_list) |
BOOTSTRAP_RELAYS |
manual |
ProfileStore::handle_requests |
sync_bootstrap_only(metadata) |
BOOTSTRAP_RELAYS |
manual |
| All publishes | client.send_event(event).broadcast() |
pool write relays | no gossip |
Consequences:
INDEXER_RELAYSare connected but receive no request: beingDISCOVERY-only they are excluded from every manual target, and no Auto request exists to use them. They become useful only once an Auto request runs, or if a manual request names them.- The NIP-34
ptags on events do not influence fetching.extract_public_keysreads the filter, not the events, and the repo filters carry no pubkeys outside the announcement/state and author-deletion filters. - The pool has no
max_relays(builder.rsdefaultNone) and relays are never removed. Any future Auto traffic accumulates GOSSIP relays for the lifetime of the session.
3. The NIP-34 p tag, per event kind
The claim "activity events already carry a p tag pointing at the repository
owner" is true for the root kinds and false for kind-1111 comments:
| Kind | Lowercase p |
Source |
|---|---|---|
| Issue 1621 | repository owner | nostr/src/nips/nip34.rs::GitIssue (L476-L484) |
| Patch 1617 | repository owner | nip34.rs::GitPatch (L575-L583); RepoStore::publish_patch_series builds patches by hand and adds Tag::public_key(owner) explicitly |
| PR 1618 | repository owner | nip34.rs::GitPullRequest (L654-L664) |
| PR update 1619 | repository owner | nip34.rs::GitPullRequestUpdate (L721-L730) |
| Status 1630-1633 | owner and root author | RepoStore::set_status / publish_applied_status push both explicitly |
| Comment 1111 | parent author, not necessarily the owner | CommentBuilder emits the root as uppercase E/K/P and the parent as lowercase e/k/p (nostr/src/nips/nip22.rs::as_vec). RepoStore::comment_builder passes the root as the parent for top-level comments, so p is the issue/PR author there. |
So a single Filter with both .coordinate(addr) (#a) and
.pubkey(maintainers) (#p) would drop comments on roots authored by
non-maintainers. Keeping the existing #a-only filter and adding a second
#p-scoped filter is safe. The #p-scoped filter buys read-relay ("inbox")
targeting through gossip, not root coverage.
4. Findings
- Startup ordering hazard (medium).
Backend::newdefersbootstrap, which adds relays inside a background task;RepoListStore::newdeferssubscribe_remote, which immediately background-spawnssync_bootstraps. If the sync reachespool.syncbefore the relays are in the pool, every filter fails withrelay not found. The global sync runs only once per session and is not retried, so a lost race means no fresh announcements, states or deletions are fetched until the next launch (the persistent LMDB still supplies what previous sessions stored). Fix options: add the bootstrap relays before spawning, or make the sync helpers ensure their relays. - Profile coverage vs. the indexers (medium). Moving
wss://profiles.nostr1.comfromBOOTSTRAP_RELAYStoINDEXER_RELAYS(working tree) meansProfileStore, which syncs metadata overBOOTSTRAP_RELAYS, no longer reaches it.INDEXER_RELAYSthemselves are currently inert (see above). Decide whether profile metadata should target a profile indexer explicitly, and whetherprofiles.nostr1.comindexes kind10002at all. - State is owner-only (low, verify against NIP-34).
filters::stateandrepo_filtersfetch kind30618with.author(addr.public_key), the announcement author. GitWorkshop reads state from every confirmed maintainer (useResolvedRepository.ts,stateCandidates). If co-maintainers publish state events,signedignores them. profile.rscomment wrong (cosmetic). Synced events do emitNostrUpdateon this SDK revision; the post-sync re-read inhandle_requestsis defensive, not load-bearing.- Progress fields are global (cosmetic). Overlapping
sync_bootstrapscalls sharesync_progress; the last writer wins and completion of either clears it. Progress counters from sequential filters accumulate correctly because all filters share one watch channel. - Background fetch failures are log-only (policy).
connect_repo_relaysand the per-filter sync errors are logged, not surfaced. Given the new strategy adds more fetch paths, decide whether relay reachability should feed the existinglast_error/last_warningsurfaces.
5. Consequences for the fetching strategy
- Auto and Manual can coexist, as asked. They are independent requests on the same pool; events deduplicate in the database and only new events notify.
- The gossip store must be primed by an Auto request before it can resolve
anything.
get_best_relaysis a pure store read, andensure_gossip_public_keys_freshis private to the client. Sequence Uncensored as: Auto request (freshness runs as a side effect) -> resolve maintainers viaget_best_relays(pk, All { .. })-> manual sync throughconnect_repo_relays. - The manual leg already covers GitWorkshop's "outbox and inbox".
BestRelaySelection::All { read, write, hints, most_received }returns both directions, so the activity filter does not need.pubkey(..)for reach, and the comment caveat in section 3 becomes optional rather than blocking. Adding.pubkey(maintainers)still helps the Auto leg reach maintainer inboxes and is worth doing as a second filter. - Curated can stay exactly what the code does today (announced relays,
manual). The only decision is whether the per-repository REQ against
BOOTSTRAP_RELAYSremains in Curated or not. - Publishing is unaffected; it already broadcasts to every pooled write relay and does not consult the strategy.