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
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
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
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
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
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
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
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
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
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>
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
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>
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>
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>
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
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
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
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>
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>
`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>
The service layers were being re-enabled level-triggered. Before this branch the
inner flow was a Boolean behind distinctUntilChanged, so enableServiceLayers()
ran once per real transition. It now carries (all accounts, participating
accounts), and Account has no equals — so the flow re-emits whenever the account
map changes identity, and the outer collector restarts on every
foreground/background crossing, which includes every screen-off.
That matters because ServiceWatchdogManager.schedule() calls setInexactRepeating
with FLAG_UPDATE_CURRENT and a first fire of `elapsedRealtime() + 5min`. Every
call replaces the alarm and pushes that first fire out again. Screen-on happens
far more often than every five minutes, so L5 — the layer whose entire job is
noticing a dead service and restarting it — would effectively never have fired.
NotificationCatchUpWorker was unaffected: it enqueues with
ExistingPeriodicWorkPolicy.KEEP, so repeat calls leave the existing period alone.
Enable/disable is edge-triggered again, explicitly this time rather than as a
side effect of what the upstream flow happens to emit.
Also drops ActiveSubscriptionsState.busiestPurposeFilters, which has no readers —
the cards draw their share against attributedFilters.
Verified on emulator-5554: after a cold start, HOME, and return, the foreground
service is still running and the watchdog alarm is still registered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wallet was the only account-level subscription whose lifetime was decided by
the model. CashuWalletState collected relay sets on the Account's own scope and
called subscribe/unsubscribe itself, so it ran for every Account object that
happened to be resident — including accounts loaded purely so pushed gift wraps
could be decrypted by their owner, which have no wallet anyone is looking at.
Gating that with a subscribedAccounts flow made it worse: a model object read the
relay layer's bookkeeping ("is this pubkey REQ-ing anywhere?") to decide whether
to talk to relays, and encoded a proxy for the rule rather than the rule. It
happened to work only because the registry mounts every account in the
foreground.
CashuWalletEoseManager is a PerUserEoseManager in the account group, exactly like
NwcNotificationsEoseManager already was. Start and stop now come from the same
mounts as notifications, DMs and gift wraps — the screen's for the account on
show, the registry's for the rest — and will follow whatever the
foreground/background rule becomes without knowing about it. Per user, never
merged: each wallet reads its own outbox for its own events and its own inbox for
nutzaps addressed to it.
commons keeps the query shape as a plain cashuWalletFilters() function and loses
the CashuWalletFilterAssembler wrapper. subscribedAccounts disappears entirely —
from Account, AccountCacheState, AppModules, both AccountViewModel previews, both
androidTests, and AccountFilterAssembler itself.
Verified on the wire, not just on the screen: with REQ logging temporarily on,
134 REQ frames carried kind 17375 across 9 relays naming all 4 logged-in accounts,
and 90 carried kind 9321. The subscriptions screen agrees — Wallet and Nutzap
Inbox under each of the four.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A merged filter keeps one EOSE cursor per relay. That is right while the set of
accounts it covers is stable and silently wrong the moment it grows: the account
that joins inherits a cursor it never earned, so `since` skips everything older
and its history is never requested. The subscription looks healthy, reports EOSE
and delivers new events forever after — it just never backfills.
This is the normal startup path, not an edge case. The account on screen mounts
from Compose and EOSEs within a second or two; the registry brings the rest in a
moment later, onto relays that have already reported EOSE. Account switching and
mid-session login hit it the same way.
It bites metadata harder than notifications. Notifications have a backward pager
that is deliberately still per-account and can recover the gap. Metadata has
none, so an account that joined mid-session would go without its own profile,
follows and lists until the next launch cleared the in-memory cursor.
MergedAuthorTracker records which accounts each relay's filter last covered and
reports growth; both merged managers drop that relay's cursor when it grows, so
the next filter asks from scratch. Growth only: an account leaving takes nothing
with it, and the accounts that remain already have their events.
Verified: MergedAuthorTrackerTest covers first-sight, join, no-op, leave, swap,
per-relay independence and clear — mutation-checked by making the predicate
return false, which fails three of the seven. On emulator-5554 all four accounts
keep their Notifications, DM Inbox and Account's data, and nos.lol refusals over
three clean cold starts were 0/0/7 against 8 before, so the extra refetch costs
nothing measurable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from reading the branch end to end:
- AccountNotificationsEoseFromInboxRelaysManager carried a `pubkeyOf` helper with
no callers and a comment inventing a reason for it. Removed.
- `limit = 1 * pubkeys.size` said the multiplication out loud for no reason.
- The two merged managers scale their limits differently and nothing said why.
Metadata multiplies by account count, notifications does not, and that is
deliberate: metadata is replaceable so its limit is a safety bound, while
notifications are a stream whose limit is a page size — scaling would ask 4x
the data on every cold start and relays clamp it anyway. Documented, along with
the consequence (a busy account can crowd a quiet one out of the shared
newest-N) and what recovers it (the history pager, left unmerged).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every producer of FOLLOW_LISTS is a browse filter on the Follow Sets feed —
FilterFollowSetsBy{Authors,Community,AllCommunities,Geohash,Hashtag} and Global,
all under discover/nip51FollowSets. Nothing account-level assigns it: the
account's own follows ride ACCOUNT_DATA with the rest of its lists. So the
`runsInBackground = true` described a producer that does not exist.
The flag has no code consumer — isWorthNamingInNotification keys off `group`, not
this. It is read by whoever is looking at a backgrounded breakdown and asking
which of these purposes is allowed to be there, and the enum says a
`runsInBackground = false` purpose appearing while backgrounded is a leak. A
browse feed claiming to be allowed is exactly the entry that would get waved
through.
Nothing behaves differently. It stops the taxonomy lying to the next person who
uses it to diagnose one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hex-string cache key with a small CacheKey holding the raw
(privateKey, pubKey) references — no byte copy and no ~400-byte hex String per
lookup. The precomputed Int hash is only a bucket selector; equals() does the
authoritative full-content comparison, so collisions share a bucket and are
disambiguated rather than returning the wrong peer's secret. Same value-type
immutability contract already relied on by X25519KeyPair as a map key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
Two hardening fixes in the NIP-44/NIP-04 encryption primitives:
1. SharedKeyCache keyed conversation/shared secrets by a 32-bit polynomial
hashCode of (privateKey || pubKey). Distinct peers whose bytes hash to the
same Int collided on one LRU slot, so get() could return one peer's key for
a message meant for another — a silent wrong-key encrypt/decrypt. The hash
collides independently of the private key, so a pubkey aliasing a victim's
contact is grindable. Now keyed on the full (priv || pub) byte content.
Added SharedKeyCacheTest, which fails on the old hashCode key and passes now.
2. NIP-44 MAC verification (Hkdf.fastExpand and Nip44v2.checkHMacAad) used
ByteArray.contentEquals, which short-circuits on the first mismatching byte
and leaks a timing side channel on the HMAC tag. Switched to a shared
equalsConstantTime helper (utils/ConstantTime.kt), matching the constant-time
tag checks already used in ChaCha20Poly1305/XChaCha20Poly1305/MLS.
Also corrects the X25519 KDoc, which claimed the JVM/Android actual delegates to
java.security XDH; it is in fact the same pure-Kotlin Montgomery ladder as the
other targets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
"Observing Profiles" showed up for accounts that had no screen at all, which is
the one thing that purpose is explained as never being: "profiles of the people
currently on screen".
ContactCardFilters has two builders sharing SubPurpose.PROFILE_METADATA, and only
one of them observes anything. filterContactCardsToTargetKeysFromTrustedAccounts…
fetches kind:30382 cards ABOUT the users on screen — that one is right.
filterContactCardsByAuthorInTheRelay fetches the account's OWN nicknames in the
login-time bulk download, from the account's own relays, with nobody on screen;
its own KDoc said as much while the purpose said otherwise.
It rides filterAccountInfoAndListsFromKey, so every logged-in account carried one
of these per relay — Dr Martha Liz's two relays showed as "Observing Profiles ·
2 filters · 2 relays" for an account nobody was looking at. It is account data,
and now says so.
Verified on emulator-5554: only the account on screen still reports Observing
Profiles; the other three carry Account's data alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.
Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.
Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.
The commons composeResources tree was already complete for all four locales.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
The row sits under an account header that already names whose it is, and with
several accounts on screen "Your Account's data" repeated under someone else's
name reads as a contradiction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Your Account's data" fell into "Not attributed" one commit ago. None of the
metadata builders ever named their own account — they relied on
PerUserEoseManager stamping `attributedTo(pk)` over whatever they returned. Moving
the manager to SingleSubEoseManager took that away, because that base class
cannot attribute: one of its filters may serve several accounts, so there is no
single pubkey to stamp.
So the builders name themselves now, which is where the knowledge actually is.
Every filter in this package fetches an account's OWN profile, lists, bookmarks
and recent posts, so `authors` and the accounts asking are the same list.
The one exception is filterBasicAccountInfoFromKeys, which fetches OTHER people's
profiles for the account-switcher avatars: there the authors are precisely not
the requester, so it takes `requestedBy` and is attributed to that instead. Its
parameter carries the reason, since it reads like an inconsistency otherwise.
Notifications were unaffected — those builders already set accountPubKeys
themselves, which is why only this purpose lost its account.
Verified on emulator-5554: "Your Account's data" appears under all four accounts
again, and the unattributed count is back to 8 filters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Account metadata was the largest single contributor to blowing a relay's
subscription cap: seven filters in a subscription, repeated once per logged-in
account. Every one of them is `authors`-keyed, so a relay that several accounts
read from can be asked about all of them by widening `authors` — the same shape
of merge the notification tail just took, and for the same reason.
The per-account `limit`s are summed rather than shared. These are mostly
replaceable events, so the limit is a safety bound rather than a page size, and
scaling it by the number of accounts leaves each one exactly the headroom it had
alone. That is the difference from gift wraps, which stay per-account because
their `limit` IS a page size over unsolicited content.
Measured on emulator-5554, four accounts, cold start, counting nos.lol's
`ERROR: too many concurrent REQs`: 13 before either merge, 11 after
notifications, 8 after this. The subscription count on that relay went from
24-26 to 23, against its cap of 20.
So the per-account multiplication is no longer the driver, and the remaining 23
are not account-level at all — they are the feed, channel and finder assemblers.
Cutting further means looking there, not at more merging.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every account-level loader is a PerUserEoseManager, which opens one subscription
per user. With four accounts open that multiplies, and shared relays run out of
room: nos.lol (strfry, `max_subscriptions: 20`) answered `ERROR: too many
concurrent REQs` — 0 times before this branch, 7 with three accounts subscribed,
13 with four. The refusal arrives as a NOTICE, which carries no subscription id
and never reaches RelayReqRefusals (wired only to CLOSED), and "too many
concurrent REQs" matches none of its markers. So the relay drops those REQs while
we still believe they are live: they never EOSE and never deliver.
Notifications are `#p`-scoped, so a relay serving several accounts can be asked
about all of them in one filter naming every pubkey — same query, wider tag. The
manager moves to SingleSubEoseManager: one REQ per relay instead of one per
(account, relay), with the `since` floor taken per relay as the OLDEST of the
participating accounts' floors so widening for one can never cut another short.
Gift wraps deliberately do NOT merge. They are unsolicited and opaque to the
relay, so it cannot rate-limit them per recipient; a merged query would let one
spammed account eat the shared `limit` and starve every other account's DMs.
Each account keeps its own budget there.
A merged filter serves several accounts, so accountPubKey becomes accountPubKeys.
The subscriptions screen shows such a filter under each account it serves —
"why is this relay busy for me" has to be answerable per account — which makes
the per-account counts a breakdown of a shared filter rather than a partition of
the total. attributedFilters is that sum, and is what a card's share is drawn
against now; dividing by the wire total would read as 100% for each of two
accounts sharing one filter.
Measured, and it is only part of the answer: nos.lol carries 24-26 subscriptions
against its cap of 20, and this removes 3 of them. The rest are the other
per-account managers — account metadata alone is 7 filters in a subscription per
account, and gift wraps another. Merging metadata (it is `authors`-keyed and
merges the same way) is the next lever; this commit does not get us under the cap
on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>