Keep both halves of a NIP-65 relay list, and publish only to one #12
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/nip65-outbox"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Issue
0ac127e0. Stacks on #9 (fix/bunker-relogin), because the code it changes is thefetchRelayList/seedRelaysFromNip65path that #7 rewrote.Item 1 of the issue's suggested order ships here. Items 2 and 4 do not, and the reason is a number, not effort — see below.
The bug
fetchRelayListfiltered relay tags withon the grounds that "a read-only relay is not somewhere to publish". True, and the wrong half to discard: in NIP-65 a user's read relays are their inbox. Dropping them meant Stash could neither read from them nor show them in settings — a user whose relay list marks a relay
readwatched it vanish from "Your relay list".What changed
Nip65.kt, pure. Marker rules out of the SDK call and into something testable. The decisions that are ours rather than the spec's are written down where they are made: an unrecognised marker falls back to the spec default (both halves) rather than dropping a relay the user did publish; markers are trimmed and case-folded; a relay named oncereadand oncewritemerges to one entry doing both, deduped on the same key the rest of the app uses sowss://nos.loland the SDK-normalisedwss://nos.lol/are one relay.Publishing is aimed, not broadcast. Keeping the inbox half creates the risk it was wrongly protecting against — the working set is also the publish target, so un-dropping those relays would start putting bookmarks on relays the user marked inbox-only.
publishUrlson the repository,prefs.publishRelays= app relays + write-marked. Null means "everywhere", i.e. exactly the old behaviour for anyone whose relay list has never been read. Passed at construction as well as at login, because a cold start of an already-signed-in account publishes before re-seeding runs. Cleared on logout with the rest of the account state.Gossip: measured, works, deliberately off
ClientBuilder.gossip(NostrGossip.inMemory())is the switch.ClientOptions.gossip(GossipOptions)alone is not — it reads identically to the negative control. Both established with controls on either side (a client told only about relay A returning an event that exists only on relay B, and returning nothing when built plain).What stops it is the cost. Fresh relays, harness guard first, hit and miss authors measured in the same process:
kind:10002kind:10002A flat ~10s on the first resolution of any author, hit or miss — I expected the miss to be the expensive case and it is not. Cached afterwards, but per author, not per client: three different unknown keys on one client cost 10055 / 10046 / 10045 ms. So the discover feed would pay it once per stranger and a cold start before the user's own bookmarks paint — a worse stall than the 12s one #1 removed.
Nothing exposed tunes it:
GossipOptionscarries only the five per-user relay counts, andNostrGossiphas no methods at all, so the table cannot be pre-warmed. Enabling it turned the device suite from 84/84 intostarted=50inside a ten-minute budget.Issue item 3 (in-memory vs SQLite) is not a decision:
inMemory()is the only backing in these bindings andNostrGossipInterfaceis an empty marker, so we cannot supply our own.Verification
started == numtests, 152 s.Nip65.kt: 15/15 caught. One survivor on the first pass — deleting.trim()from the marker read changed nothing, because no test used a padded marker. Untrimmed," read "falls through to the unrecognised-marker branch and widens an inbox-only relay to both halves, which is the exact bug this file exists to prevent. Test added, row re-run, now caught.the inbox relay is missing from the relay list: [ws://10.0.2.2:10547].sendToPublishSetfails exactly one test,bookmarks_are_not_published_to_an_inbox_only_relay, withthe bookmark was published to a relay the user marked read-only.OutboxE2ETestruns against two real relays because one cannot express "where we read" versus "where we publish";e2e-direct.shgains an ordinary relay on :10551. The publish test carries a positive control that the bookmark reached the write relay, so "landed nowhere" cannot pass as "correctly withheld". Its third test pins per-author routing as deliberately absent and names the 10s in its own failure message, so switching gossip on turns it red rather than leaving the decision to be rediscovered in three months.Not verified: any of this on a real phone. Same standing caveat as the rest of the 0.12 stack.
`fetchRelayList` filtered relay tags with it.size >= 2 && it[0] == "r" && (it.size < 3 || it[2] != "read") on the grounds that "a read-only relay is not somewhere to publish". True, and the wrong half to discard. In NIP-65 a user's **read** relays are their **inbox** — where other people send them things. Dropping them meant Stash could neither read from them nor show them in settings, so a user whose relay list said `read` on a relay saw it vanish from "Your relay list" entirely. The marker rules now live in a pure `Nip65.parse`, away from the SDK call, because they are rules and rules deserve tests. The decisions that are ours rather than the spec's are written down where they are made: an unrecognised marker falls back to the spec default (both halves) rather than dropping a relay the user did publish; markers are trimmed and case-folded; a relay named once `read` and once `write` merges into one entry doing both, deduped on the same key the rest of the app uses so `wss://nos.lol` and the SDK-normalised `wss://nos.lol/` are one relay. Keeping the inbox half creates the risk it was wrongly protecting against. The working set is also what we publish to, so un-dropping those relays would start putting the user's bookmarks on relays they marked as inbox only. So publishing is now aimed rather than broadcast: `publishUrls` on the repository, `prefs.publishRelays` = app relays + write-marked. Null means "everywhere", which is exactly the old behaviour for anyone whose relay list has never been read. It is passed at construction as well as set at login, because a cold start of an already-signed-in account publishes before re-seeding runs. Gossip is deliberately NOT enabled, and `client()` says why at length. `ClientBuilder.gossip(NostrGossip.inMemory())` is the switch — measured, with both controls, and `ClientOptions.gossip(GossipOptions)` alone is not. What stops it is the cost: first resolution of ANY author is a flat ~10s in the 0.44.6 bindings — 10057ms for an author whose kind:10002 is on the connected relay, 10044ms for one with no relay list at all, against 12-43ms for the same fetch on a plain client. Cached afterwards (~35ms) but per AUTHOR, not per client, so the discover feed would pay it once per stranger and a cold start before the user's own bookmarks paint. Nothing exposed tunes it: `GossipOptions` carries only per-user relay counts and `NostrGossip` has no methods to pre-warm. Enabling it turned the device suite from 84/84 into `started=50` inside a ten-minute budget. `OutboxE2ETest` runs against two real relays because one cannot express "where we read" versus "where we publish" at all; `e2e-direct.sh` gains an ordinary relay on :10551 for it. The publish test carries a positive control that the bookmark reached the write relay, so "landed nowhere" cannot pass as "correctly withheld". Its third test pins per-author routing as deliberately absent and names the 10s in its own failure message, so switching gossip on turns it red rather than leaving the decision to be rediscovered. Measured at this commit: JVM 402/402 (0 failures, read from the XML), device 84/84 with started == numtests, 152s. Refs: issue 0ac127e0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.