Commit Graph
18233 Commits
Author SHA1 Message Date
Claude ba35a87a7c refactor: drop the ceiling params; bound waits by progress instead
Follow-up audit on the timeout normalization. Three findings.

1. The maxTotalMs I added to fetchFirst and multi-relay count was the
   wrong shape twice over. A hard wall-clock bound already composes at the
   call site -- withTimeoutOrNull(ms) { fetchFirst(...) } -- so putting it
   in the signature duplicates what the caller has for free. And it was
   papering over the real defect: repeat chatter from a relay already
   accounted for (a CLOSED/reconnect loop, a duplicate COUNT) was treated
   as activity and restarted the idle window, so a flapping relay could
   hold the call open indefinitely. Both now reset the window only on
   genuine progress -- an event, or the first terminal signal from a relay
   still being waited on -- which is the rule the negentropy watchdog
   already applies to NOTICE/CLOSED chatter, and which makes both calls
   self-bounding at one window per relay. Ceiling params removed; the
   overflow guard they needed goes with them.

2. fetchFirst could drop a match: an event landing after the last terminal
   signal but before unsubscribe was left unread in the channel and the
   fetch reported nothing found. Added the post-loop drain that
   fetchAllWithHooks already does. Covered by a test.

3. fetchAllPages published its per-page counters across threads without a
   barrier on the idle path. received/delivered/pageMinTs/idsAtPageMin are
   written on the relay reader thread and read by the driver once the wait
   ends; the EOSE path gets happens-before from the channel, the idle path
   had none, so the driver could read a stale pageMinTs (ending the walk
   early) or an unsafely published idsAtPageMin. The volatile IdleClock
   bump now runs in a finally, so it covers every event including the
   early-returning duplicate and orders after the counters.

Also folded the single-relay count channel close into its finally, so a
throw mid-wait cleans up like every sibling accessory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:26:58 +00:00
Claude ac6034f764 refactor: drop fetchAllPages' maxPageMs ceiling
The per-page wall-clock ceiling added alongside the idle-window change did
not do what it claimed. fetchAllPages waits inside a while(true): when a
page's wait ends, the loop advances the until cursor and issues the next
REQ rather than returning, so a ceiling bounds one page, never the call.
Measured against a relay trickling events forever with an unbounded
filter, maxPageMs=400 produced 8 REQs and no return -- the walk ran until
the caller cancelled, exactly as it would with no ceiling at all.

It also made truncation unsafe. Cutting a page mid-stream advances until
to the oldest event received so far, which only preserves the set if the
relay streams strictly newest-first -- NIP-01 recommends that but does not
require it -- so a ceiling firing on an out-of-order relay can skip the
not-yet-sent events above that cursor. That is the same class of silent
gap the idle window was introduced to close.

What actually bounds a paged download is the filter's limit (already the
documented way) or cancelling the caller, which the ensureActive() at the
top of each page honors. Removed from fetchAllPages, fetchAllPagesFromPool
and fetchAllPagesFromPoolWithHooks; a regression test pins the real
contract so nobody re-adds a ceiling believing it caps the walk.

maxTotalMs stays on fetchAll/fetchAllWithHooks, fetchFirst and multi-relay
count: those wait in a single loop and return, so there the ceiling
genuinely ends the call (each is covered by a test asserting it does).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 03:13:53 +00:00
Claude 53be8d1755 fix: harden accessory timeout ceilings and count() listener cleanup
Audit follow-ups on the idle-window normalization:

- maxTotalMs/maxPageMs defaults are timeoutMs * 10, which silently
  overflows to a negative Long for an effectively-infinite idle window
  (Long.MAX_VALUE * 10 wraps to -10). withTimeoutOrNull then expired
  immediately, inverting 'wait forever' into 'never wait'. All three
  ceilings (fetchAllPages, fetchFirst, fetchAllWithHooks) now treat a
  non-positive ceiling as uncapped, matching the idle window's <= 0
  convention. Regression-tested via fetchFirst.
- count(filters) leaked its RelayConnectionListener and left COUNT subs
  open if the caller cancelled mid-wait or a listener threw: the cleanup
  ran as straight-line code with no try/finally, unlike every sibling
  accessory. Wrapped so unsubscribe + removeConnectionListener + channel
  close always run.
- New FetchFirstIdleTimeoutTest pins fetchFirst's idle-window semantics
  (signals restart the window, silence costs exactly one window, the
  ceiling stops endless terminal chatter, overflow means uncapped).
- README: note publishAndConfirm's fixed window as the deliberate
  exception, and correct the fetchAllPagesFromPool row, which claimed
  cross-relay dedup the function explicitly does not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:48:15 +00:00
Claude 431ad153bc fix: normalize accessory timeouts to idle windows (time since last relay message)
fetchAllPages waited for each page's EOSE under a hard wall-clock
withTimeoutOrNull, unlike fetchAll / fetchAllWithHooks / the negentropy
sync, whose timeouts are idle windows reset by every arriving message.
A slow-but-streaming page could be silently truncated, and a relay
slower than timeoutMs to first byte was mistaken for a drained set.

- Extract IdleClock + receiveWithinIdle out of NostrClientNegentropySyncExt
  into a shared internal IdleWatchdog.kt and document the package-wide
  convention in the accessories README.
- fetchAllPages (+ pool/hooks variants): the per-page timeout is now an
  idle window bumped by every event; a maxPageMs ceiling (10x, matching
  fetchAllWithHooks' maxTotalMs) backstops a never-EOSE trickle. A
  non-positive ceiling means uncapped, mirroring the idle <= 0 convention.
- fetchFirst: the wait restarts on every arriving signal instead of one
  hard deadline across all relays; maxTotalMs ceiling added.
- count (multi-relay): each arriving COUNT result restarts the window;
  maxTotalMs ceiling added. Single-relay count is trivially idle already.
- NegentropyStoreSync page fallback clamps a disabled watchdog (0) to
  DEFAULT_DOWNLOAD_IDLE_MS like fetchByIds, instead of paging unbounded.
- New NostrClientFetchAllPagesIdleTimeoutTest pins the semantics
  (verified to fail against the old hard-deadline wait).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZe1Pq2ejoHehdufhPDRgb
2026-08-03 02:35:10 +00:00
Vitor PamplonaandGitHub bf41e75b78 Merge pull request #3848 from vitorpamplona/fix/count-ignores-default-limit
Stop COUNT from inheriting the default page size
2026-08-02 13:15:43 -04:00
Vitor PamplonaandClaude Opus 5 a76a1911ea Stop COUNT from inheriting the default page size
A relay holding 12,289,614 profiles answers

  ["COUNT","c1",{"kinds":[0]}]  ->  {"count":500}

LimitsPolicy ran the same clampLimits over CountCmd as over ReqCmd, so
an unbounded COUNT was given RelayLimits.defaultLimit and the store then
counted at most that many.

defaultLimit answers "how many events should a REQ return when the client
names none". A COUNT returns no events, so the question has no meaning
for it, and applying the answer anyway turns every unbounded COUNT into
min(matches, defaultLimit).

The failure is quiet, which is what let it survive: 500 is a plausible
number, and the kinds under the default answered correctly. On the relay
that surfaced it, kinds 1 and 10040 were right while 0, 10002 and 30382
were all exactly 500.

maxLimit still applies — a client asking to count at most N is asking
something a relay may bound. Only the invented default is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:14:07 -04:00
Vitor PamplonaandGitHub e10f76665f Merge pull request #3846 from vitorpamplona/claude/30382-rank-follower-providers-j2cnb9
fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
2026-08-01 21:17:36 -04:00
Claude 6cd91bb058 fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
updateFilter only added the rank provider to the trusted-author set, so
when the follower-count provider differed (different pubkey and/or
relay), its kind:30382 cards were never requested from the relay.
followerCountStrFlow then filtered for a signer whose cards never
arrived and rendered "--" forever.

Add liveUserFollowerCount (with its relayUrl) into the same mapOfSet
block, symmetrically with liveUserRankProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7AA1AxqA6StnPVdjGDcwv
2026-08-02 00:43:27 +00:00
Vitor PamplonaandGitHub 7922f9a4d1 Merge pull request #3845 from vitorpamplona/claude/explained-filter-test-warnings-clpcv8
Test: Type ExplainedFilter.copy() result as base Filter
2026-08-01 14:08:56 -04:00
Claude 0cc3f72f55 fix: resolve always-true is-check and redundant cast warnings in ExplainedFilterTest
Declaring 'advanced' as the base Filter type keeps the copy() regression
guard as a genuine runtime assertion instead of a compile-time triviality,
which is what the compiler was warning about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KG9YkeFp6zyth5J364DLF
2026-08-01 18:03:03 +00:00
Vitor PamplonaandGitHub 31c3eaf2b3 Merge pull request #3844 from vitorpamplona/claude/code-quality-class-decoupling-rrt199
refactor: decouple LocalCache and Account god classes (behavior-preserving)
2026-08-01 13:31:51 -04:00
Claude 92ca11a583 chore: move stray NIP-29 section comment to AccountRelayGroupActions
The relay-group section header and joinRelayGroup KDoc were left
dangling at the end of AccountConcordActions when the clusters were
split into separate files; reattach them to the function they describe.
Found by the post-refactor audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:27:56 +00:00
Claude dc5e4562fd fix: restore two Marmot log messages mangled during extraction
The account-qualification regex in the AccountMarmotActions extraction
also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL'
inside two log string literals, changing log output text. Restore the
original wording. Found by the post-refactor equivalence audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:22:31 +00:00
Vitor PamplonaandGitHub e0ea365368 Merge pull request #3843 from vitorpamplona/fix/rtt-open-from-handshake
rtt-open is the transport's handshake, not our own queueing
2026-08-01 13:22:11 -04:00
Claude 1dba215d4d refactor: extract AccountZapActions from Account
Moves the ~270-line zap/payment orchestration (NIP-57 zap requests,
NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC
onchain zaps/sends/splits) into AccountZapActions, exposed as
account.zaps. The onchain backend-not-configured constant moves with
it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet
viewmodels, blossom payments, app functions) now call account.zaps.*
directly. Moved code is unchanged except for account. qualification.

Completes the Account decoupling series: Account.kt went from 6228 to
3618 lines across EventBroadcaster, AccountConcordActions,
AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:16:51 +00:00
Claude d2c9593919 refactor: extract AccountRelayGroupActions from Account
Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration
(join/leave/create/delete/archive, threads, invites, pins, member/role
management, metadata edits, Buzz DMs/jobs/workflows/typing,
community member add/remove) into AccountRelayGroupActions, exposed as
account.relayGroups. External callers now use account.relayGroups.*
directly. Moved code is unchanged except for account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:11:04 +00:00
Claude 20149e2600 refactor: extract AccountMarmotActions from Account
Moves the ~540-line Marmot/MLS orchestration cluster (group create/
leave/reset, member add/remove via key-package fetch, admin grant/
revoke, metadata updates, group messaging, key-package publishing and
relay resolution) into AccountMarmotActions, exposed as account.marmot.
External callers (marmot group screens, AccountViewModel forwarders,
NotificationReplyReceiver, DecryptAndIndexProcessor) now call
account.marmot.* directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:07:24 +00:00
Claude c0932e0330 refactor: extract AccountConcordActions from Account
Moves the ~1,000-line Concord orchestration cluster (join/create/invite
flows, channel messages/reactions/edits/typing, roles and moderation,
refound/rekey/stranded-recovery, metadata + channel management,
control-plane sync) into AccountConcordActions, exposed as
account.concord. The two Concord file-level constants move with it.

Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex)
stays on Account since ConcordSessionManager is constructed with it,
as do the cross-feature sendMinichatReply and the read-path
isConcordBanned policy. External callers (Concord screens,
AccountViewModel forwarders, note action menus) now call
account.concord.* directly - no delegating shims.

Moved code is unchanged except for account. qualification.
Account.kt: 6228 -> 4935 lines so far in this series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:05:00 +00:00
Vitor PamplonaandGitHub dc1f0dd995 Merge pull request #3842 from vitorpamplona/claude/liveventstore-searchextensions-3ya4ir
Add StoreQueryContext for observer-relative ranking in stores
2026-08-01 13:03:08 -04:00
Vitor PamplonaandClaude Opus 5 7d91931b75 rtt-open is the transport's handshake, not our own queueing
It was measured from onConnecting to onConnected, which includes the time
the call sat in the client's dispatcher queue. Under a 16,507-relay
fan-out that queue dominates everything else: published records showed a
median rtt-open of 33.5 SECONDS and a max of 90, against a true minimum
of 140ms.

That is the field aggregators rank relays by, so it was worse than
publishing nothing — a signed claim that healthy relays are slow, when
the slowness was ours.

pingMillis already carried the right number and was being handed to the
listener unused: BasicOkHttpWebSocket computes it as
receivedResponseAtMillis - sentRequestAtMillis, so it starts when the
upgrade request actually goes out and excludes everything before it.

Zero or negative means the transport could not time the handshake, and
then no timing is published rather than a fabricated one — the same rule
the rest of this class already followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:59:22 -04:00
Claude f2e7a07a97 fix(relay): make the per-connection auth set a copy-on-write snapshot
Audit finding on the StoreQueryContext seam: RelaySession kept
authenticatedUsers as a plain mutable LinkedHashSet and handed out a
live view through RequestContext. A store (or EventSource) reading the
set during a long REQ replay — exactly what StoreQueryContext invites —
could race a concurrent NIP-42 AUTH commit on another coroutine:
iteration vs. add on an unsynchronized set is a
ConcurrentModificationException or a torn read.

The engine now swaps an immutable Set behind a @Volatile field on each
AUTH (the only writer), so every read is a consistent snapshot and
holding one across a replay is safe. AUTH is rare; the copy is off the
hot path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:58:36 +00:00
Claude 7936cab9d5 refactor: extract EventBroadcaster from Account
Moves the sign-and-publish choke point out of Account into an
EventBroadcaster class: relay-set computation (outbox model, hints,
channel home relays, broadcast lists, DM inboxes, the recursive
linked-event descent) plus every publish path (sendAutomatic,
sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast,
signAndSendPrivately*, signAndComputeBroadcast,
signAnonymouslyAndBroadcast, republishEventsTo).

Account keeps one-line delegates so its 85+ internal call sites and all
external callers are unchanged; upcoming Account*Actions extractions
will call the broadcaster directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:54:52 +00:00
Claude 5311efe65e refactor: extract CachePruner and CacheSearch from LocalCache; move Dao out of ui
Two read/reclaim policy clusters leave the LocalCache god object into
sibling classes in the same package, each taking the cache as its only
constructor dependency so the policies are testable in isolation:

- CachePruner: cleanMemory/cleanObservers, the six prune passes
  (hidden/old/expired/superseded/replies+reactions), and the shared
  unlinkAndRemove removal primitive (with removeIfWrap and
  editedTargetIdOf). LocalCache.deleteNote and
  DecryptAndIndexProcessor now call pruner.unlinkAndRemove;
  MemoryTrimmingService drives cache.pruner.*.
  refreshDeletedNoteObservers becomes internal so the pruner can
  notify observers.

- CacheSearch: findUsersStartingWith(username, account),
  findNotesStartingWith, and the three channel prefix searches, plus
  their private exclusion rules. Callers (SearchBarViewModel,
  AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use
  cache.search.* directly - no delegating shims left behind.

Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into
the model package where its implementor (LocalCache) and its types
live, removing a model-layer interface defined in a UI file.

All moved code is unchanged except for cache. qualification; behavior
is identical. LocalCache.kt: 4554 -> 3921 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:48:21 +00:00
Claude 2e90cc24ba feat(quartz): move NIP-50 extension handling into the stores; add IEventStore seams
The IEventStore contract now defines the seams a store implementation
needs instead of having the relay layer decide for it:

- NIP-50 search extensions: LiveEventStore no longer strips key:value
  tokens before the store sees them. Filter.search reaches every store
  verbatim, and each implementation decides which extensions it
  supports — the store, not the middleware, is the only component that
  knows whether sort:rank is a directive or noise. The built-in SQLite
  and filesystem stores strip at their own boundary (their FTS engines
  would otherwise error on / literally match the tokens), preserving
  the NIP-50 'ignore unsupported extensions' behavior end to end.
  Extension-aware stores need no side channel to recover the raw
  string anymore. Fs delete now checks emptiness on the stripped
  filter so an extensions-only search cannot wipe the store.

- Caller identity: new StoreQueryContext coroutine-context element,
  installed by LiveEventStore around every REQ/COUNT store call when
  the connection has NIP-42-authenticated pubkeys. Observer-relative
  stores (web-of-trust ranking, for-you relevance) read it off the
  coroutine context; ranking context only, never match-set changes.

- Negentropy liveness: snapshotIdsForNegentropy gains an optional
  onProgress hook so mirrors syncing huge corpora get a running count
  through the interface type instead of a concrete-class overload.
  SQLite reports from its row loop; the interface default streams and
  reports too.

- FtsReindexProgress.cursor documented as opaque and store-defined so
  resumable-reindex callers never assume id semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:44:31 +00:00
Claude 532b9e67fe refactor: collapse LocalCache event dispatch into grouped when branches
justConsumeInnerInner was one when(event) with ~290 branches, of which
172 were identical single-call bodies routing to consumeBaseReplaceable
or consumeRegularEvent, and ~55 more were single-line Buzz consumer
calls. Since all four shared consumers take a plain Event, the
boilerplate branches are now comma-grouped into one branch per
consumer (replaceable/addressable, regular, Buzz timeline, Buzz
store-only), keeping every branch with per-kind logic exactly as it
was.

Dispatch is provably unchanged: none of the 289 event classes has a
supertype among the classes in any other branch group, so reordering
cannot shadow a branch, and the old and new type-to-consumer mappings
were compared exhaustively and are identical. The else branch still
rejects unlisted kinds, preserving the supported-kinds allowlist.

LocalCache.kt: 5155 -> 4554 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:29:08 +00:00
Vitor PamplonaandGitHub 3823eae11d Merge pull request #3840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 09:54:16 -04:00
vitorpamplonaandgithub-actions[bot] 9cb17a26e8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 13:51:50 +00:00
Vitor PamplonaandGitHub 6d518adddb Merge pull request #3841 from vitorpamplona/feat/observer-out-of-band
Let a monitor publish what it learned without dialling
2026-08-01 09:49:03 -04:00
Vitor PamplonaandClaude Opus 5 c3c20c6615 Let a monitor publish what it learned without dialling
RelayObserver is a RelayConnectionListener, so on its own it can only
report on relays something opened a websocket to. On a large fan-out that
is a small minority, and it is the wrong minority: the cheap checks that
decide NOT to dial — a TCP probe, a DNS failure, a host struck out after
repeated silence — are precisely the ones that learn a relay is gone, and
their findings had nowhere to go.

Measured on a 16,507-relay list: 104 records published. Everything else
was ruled out before the client ever saw it, so the monitor had nothing
to say about 99% of the relays it had just formed an opinion on.

record() takes those findings. Same rules as the connection path — a
relay that answered is not demoted by one failed probe, and a reachable
relay with no measured time is published with no time rather than a
fabricated zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:42:00 -04:00
David KasparandGitHub da009f36bd Merge pull request #3839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 12:07:57 +02:00
davotoulaandgithub-actions[bot] f7959571a7 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 09:31:43 +00:00
davotoula 529c114802 fix: hoist the playback-error test fixtures out of composition 2026-08-01 11:21:47 +02:00
Vitor PamplonaandGitHub 2741d32cfd Merge pull request #3838 from vitorpamplona/claude/nip42-auth-dm-delivery-test-fhn7fd
Fix InProcessWebSocket race condition in connect-time AUTH delivery
2026-08-01 01:29:24 -04:00
Claude ae61e69136 fix(relays): deliver in-process server frames only after onOpen
Nip42AuthDmDeliveryTest stalled for its full 10s timeout on CI while
passing locally. The stall is a race in InProcessWebSocket.connect():
server.connect() runs the session's connect-time policies synchronously,
so FullAuthPolicy's AUTH challenge reached the client's listener before
the socket assigned its `incoming` channel and before onOpen fired —
breaking the WebSocketListener contract (no onMessage before onOpen).

RelayAuthenticator answers that challenge on its own coroutine. When the
signed AUTH reply hit send() before the connect thread reached the
`incoming` assignment, send() returned false and the reply was silently
dropped. Nothing recovers from that: the challenge is already dedup'd as
answered, and an EVENT rejected with OK-false `auth-required:` never
re-triggers auth (only a CLOSED does), so the pending gift wrap was
never resent — exactly the CI signature (10.011s, no auth activity
between the authenticator's Init and Destroy logs).

Server->client frames now go through an outbound channel drained by a
coroutine started only after onOpen, so every connect-time frame reaches
the listener with the socket fully wired. Order is preserved by the
single drainer, same as the existing inbound path.

Both new InProcessWebSocketTest cases fail deterministically without the
reorder (the challenge always outran onOpen; a reply sent from the first
onMessage was always rejected) and pass with it, on top of the full
:geode:test and :quartz:jvmTest suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bf1Y91sfjxwi2ig4ymGTA9
2026-08-01 05:27:51 +00:00
Vitor PamplonaandGitHub 79f198c729 Merge pull request #3836 from vitorpamplona/feat/nip66-relay-monitor
NIP-66: measure relays from the traffic a client already makes
2026-07-31 23:23:23 -04:00
Vitor PamplonaandClaude Opus 5 18d229be58 Match the listener's parameter names; drop commas from test names
Native targets reject a comma inside a backticked name, so five tests
that read fine on JVM broke every Kotlin/Native build. Renamed without
them.

The override parameters now match RelayConnectionListener — pingMillis,
compressed, cmdStr, cmd, msg, errorMessage — which silences six warnings
and, more to the point, fixes a misreading: onConnected's second and
third parameters are the connection's ping and whether it is compressed,
and I had them named attempt and success.

Both were missed the same way: jvmTest passes without ever compiling the
native TEST sources. All five targets now compile, main and test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:55:36 -04:00
Vitor PamplonaandGitHub d6333989a4 Merge pull request #3837 from vitorpamplona/claude/quartz-hex-encode-decode-xggbv2
Add optimized decode64/decode128 and encode64/encode128 to Hex
2026-07-31 22:51:50 -04:00
Vitor PamplonaandClaude Opus 5 cf75272202 Fix the native build: toSortedMap is java.util
commonMain, so it compiled on JVM and broke every native target. Sorted
into a LinkedHashMap instead, which is the same output everywhere.

Found by CI on iosSimulatorArm64 because I had only compiled the JVM
target locally; all five now build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:31:39 -04:00
Vitor PamplonaandClaude Opus 5 5c24b003e7 NIP-66: measure relays from the traffic a client already makes
A monitor normally probes — opens connections purely to measure, then
throws them away. A client that is already subscribing, fetching and
publishing has better data for free: measured under real load, against
the relays it actually uses, at the concurrency it actually runs.

RelayObserver is a RelayConnectionListener, so it sees every connection
whichever code path opened it and none of them has to report anything:

  rtt-open      onConnecting to onConnected
  rtt-read      first REQ to its EOSE
  rtt-write     first EVENT to its OK
  reachable     it opened, or served something
  auth-required it sent AUTH, or CLOSED saying so
  the error, verbatim, when it never opened

Everything is OBSERVED. Nothing is copied from a relay's NIP-11: that is
the relay's own claim, available to anyone who asks, and republishing it
under a monitor's signature adds nothing but a chance to go stale. Where
the two disagree — a relay advertising open reads that then challenges
us — the observation is the half worth having, and copying the claim
would erase it. It also keeps quartz free of an HTTP dependency.

RelayMonitor is the whole wiring: construct one and connections are
measured, signed as 30166s on an interval, and folded into a cheap
in-memory isKnownDead for picking relays. That read has to be cheap — an
outbox picker runs per event — so it answers from a snapshot refreshed on
an interval, never a store query.

The signer is required. Measuring relay quality and letting others check
it IS NIP-66, and an optional signer would just add the failure mode this
library keeps designing out: configured, silent, doing nothing. A client
that should not publish does not construct one.

RelayObserver also replaces the CLI's RelayDiagnostics, which was the
same listener minus the timings. Porting it surfaced a bug both shared:
substringBefore(':') returns the WHOLE string when there is no colon, so
a relay's free-form CLOSED prose became its own tally key and the map
grew with the number of distinct sentences relays wrote. The colon is
now required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:21:00 -04:00
Claude 729fb1bc17 perf(quartz): hoist lookup tables in Hex.decode/encode/isEqual/readLong; bench new codecs
javap showed the hexToByte/byteToHex field re-loaded on every use inside
these methods (16 times per readLong call) — the JVM/ART doesn't reliably
prove the load loop-invariant. Hoisting it into a local measured ~25%
faster for decode and ~10% for isEqual and readLong on the JVM
(4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was
neutral on HotSpot but is hoisted too since ART is historically worse
at this (see the internalIsHex comment).

Branchless variants of isHex/isHex64 were also measured and were a
wash-to-slightly-worse than the branchy early-exit versions on valid
input, so those keep their current implementations.

Also adds the new exact-size codecs to the on-device HexBenchmark
(decode64, decode64OrNull, encode64, decode128, encode128, toLong256,
and the old isHex64+decode two-pass for comparison) so ART numbers can
be collected with the existing benchmark harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 01:47:01 +00:00
Vitor PamplonaandGitHub 77ff9e9699 Merge pull request #3834 from vitorpamplona/dependabot/github_actions/actions-08295fc4ea
chore(actions): bump the actions group with 5 updates
2026-07-31 21:34:08 -04:00
dependabot[bot]andGitHub 166ebc755e chore(actions): bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `5.6.0` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.1` | `3.0.2` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `actions/setup-java` from 5 to 5.6.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v5.6.0)

Updates `softprops/action-gh-release` from 3.0.1 to 3.0.2
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 00:45:45 +00:00
Claude d8bae8c628 perf(quartz): tune Hex.decodeExactOrNull at the bytecode level
javap on the previous version showed the hexToByte field re-loaded
twice per iteration, the trip count as a runtime parameter, and the
whole loop wrapped in an exception table (only needed because chars
above 0xFF overflow the 256-entry lookup table).

Now the table is hoisted into a local, the function is inline so the
32/64-byte length becomes a compile-time constant at each call site,
and out-of-range chars are rejected branchlessly: the index is masked
with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative
for any char above 0xFF and is folded into the same sign-bit
accumulator that already catches invalid hex digits. No try/catch, no
exception table, no branches in the loop.

Measured on the JVM (4096 random ids, best-of-200 rounds, two runs
with variant order reversed to rule out JIT profile artifacts):
~25% faster than the previous version and ~2x faster than the
isHex64 + decode two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:33:51 +00:00
Claude 9ea4b81c1f feat(quartz): size-enforcing Hex.decode64/128 and encode64/128
Adds exact-size codec entry points to the Hex utility so 32-byte
pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the
wrong size or invalid characters are rejected instead of silently
decoded:

- decode64 / decode128 throw IllegalArgumentException; the OrNull
  variants return null for untrusted input.
- encode64 / encode128 require exactly 32 / 64 input bytes.

The decode is single-pass: character validation is folded into the
decode loop via a sign-bit OR-accumulator (the lookup table yields -1
for invalid chars), so it is faster than the isHex64 + decode
two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:14:17 +00:00
Vitor PamplonaandGitHub 5a433663fe Merge pull request #3831 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 20:00:11 -04:00
vitorpamplonaandgithub-actions[bot] 0b77de879d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 23:55:51 +00:00
Vitor PamplonaandGitHub 50e7f43b9a Merge pull request #3832 from vitorpamplona/feat/filter-purpose-explainer
feat(relays): explain why every subscription exists, and fix what that exposed
2026-07-31 19:52:39 -04:00
Vitor PamplonaandClaude Opus 5 9fe24be6d6 perf(relays): keep the EOSE cursor lock-free on the per-event path
The previous commit guarded EOSERelayList with a lock, which put the wrong path
under it. addOrUpdate runs on **every live event** — SubscriptionListener.onEvent
calls newEose for each one, hundreds per second across a few hundred relays — so
that was one monitor for every event arriving in the app.

Almost none of those events change the map. MutableTime exists precisely so a
known relay only bumps a Long inside its own entry; the map is structurally
written on the *first* frame from a relay, plus remove() and clear(). A couple of
hundred writes for the lifetime of the process, against millions of reads.

So the map is copy-on-write behind @Volatile: replaced wholesale under the lock,
never mutated in place after publication. Readers take nothing. The per-event
bump takes nothing. Only a relay's first frame pays, and it pays a map copy of a
few hundred entries, once.

The bump itself stays unsynchronized, which is deliberate: two socket threads
racing updateIfNewer can leave the older timestamp, and this value is a floor for
`since`, so losing a millisecond re-asks for a couple of events rather than
skipping any. Documented on the method rather than fixed with an atomic that
would cost a barrier per event.

Verified: iOS, JVM, Android, desktop and the full suite build and pass. Cold start
on emulator-5554 — no fatal exceptions, no ConcurrentModificationException, 0
nos.lol refusals, 182 relays connected and the purpose breakdown populated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:47:50 -04:00
Vitor PamplonaandClaude Opus 5 5fe33a8152 fix(relays): serialize writes to the single-sub EOSE cursor map
EOSERelayList backs SingleSubEoseManager with a plain mutableMapOf, and
addOrUpdate is called from SubscriptionListener callbacks — i.e. from each
relay's own socket-reader thread. A client holding a few hundred relays therefore
had that many concurrent writers to one unsynchronized map. EOSEAccountFast wraps
its lists in a lock for exactly this reason; a bare list handed to
SingleSubEoseManager had nothing.

The race predates this branch but the branch made it load-bearing: both merged
managers (notifications and account metadata, across every logged-in account and
every relay they read) now run through SingleSubEoseManager, and the EOSE refetch
fix added a second writer in remove(). Writes are serialized with KmpLock, the
same primitive ComposeSubscriptionManager uses.

Reads still go through the live map from since(), deliberately — but the two
merged managers no longer depend on that. They were reading `since` immediately
after clearing a relay and relying on the mutation being visible through it, so
hardening since() into a snapshot later would have silently disabled the refetch
with no test to notice. Growth now zeroes the cursor for that pass explicitly, in
addition to clearing it.

Verified: both iOS targets, JVM, Android, desktop and the full suite build and
pass; a cold start on emulator-5554 shows no fatal exceptions, no
ConcurrentModificationException, and 0 nos.lol refusals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:36:28 -04:00
Vitor PamplonaandClaude Opus 5 b8d0caa8ff fix(commons): import kotlinx.coroutines.IO so the iOS targets build
`Dispatchers.IO` is an internal member in common code; the public form on
Kotlin/Native is the `kotlinx.coroutines.IO` extension property. Without that
import the member resolves on JVM and Android and fails only on iOS, which is
precisely what commons' compile-only iOS spike exists to surface.

MergedTopFeedAuthorListsState moved into commonMain in 3f4723437e and was the one
file that came across without the import — the other 22 commonMain users of
Dispatchers.IO already have it, and the only other three files that mention it do
so in comments. One error, one file, one missing import.

Reproduced locally with :commons:compileKotlinIosSimulatorArm64 before fixing;
both iOS targets, JVM, Android and the full test suite pass after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:25:26 -04:00