fix(relays): attribute every subscription to the account that asked for it

Filters carry an accountPubKey so the relay screens can group by account,
but attribution only happened for keys implementing AccountScopedQuery —
and ~50 query states held an `account` without declaring it, so the cast
failed silently and their filters showed as unattributed.

Two of the gaps were real bugs rather than display issues:

- CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping
  every account's relays, so with two wallets logged in the second was
  never subscribed and its inbox relays were queried for the first
  account's nutzaps. Now built per account.
- UserReportsSubAssembler unioned every account's follow list into one
  per-relay map, asking one account's follows of another's outbox relays.
  Now one pass per account.

Where a subscription genuinely pools accounts (outbox discovery, on-screen
event watching, profile metadata), it is attributed only when a single
account is asking rather than inventing an owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-30 21:00:03 -04:00
co-authored by Claude Opus 5
parent b6808959cd
commit 4d53bbea9e
75 changed files with 484 additions and 144 deletions
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -165,6 +166,7 @@ object ConcordSubscriptionPlanner {
lastReadFor: (channelIdHex: String) -> Long,
catchUpLimit: Int = 50,
previewLimit: Int = 10,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter> {
val authorsByChannel = LinkedHashMap<ConcordChannelId, MutableSet<String>>()
val relaysByChannel = HashMap<ConcordChannelId, Set<NormalizedRelayUrl>>()
@@ -180,6 +182,8 @@ object ConcordSubscriptionPlanner {
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord community planes",
entityIds = listOf(entry.id),
accountPubKey = accountPubKey,
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = authors.toList(),
// -1 so the last-read message (created_at == lastRead) is itself returned:
@@ -191,6 +195,8 @@ object ConcordSubscriptionPlanner {
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord community planes",
entityIds = listOf(entry.id),
accountPubKey = accountPubKey,
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
authors = authors.toList(),
limit = previewLimit,
@@ -232,6 +238,7 @@ object ConcordSubscriptionPlanner {
fun controlIsolatedFilters(
entries: List<ConcordCommunityListEntry>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
stateOf: (ConcordCommunityListEntry) -> ConcordCommunityState?,
): List<RelayBasedFilter> {
val controlSubs = controlPlaneSubs(entries)
@@ -243,7 +250,7 @@ object ConcordSubscriptionPlanner {
otherSubs += channelPlaneSubs(entry, state)
}
return relayBasedFilters(controlSubs, since).orEmpty() + relayBasedFilters(otherSubs, since).orEmpty()
return relayBasedFilters(controlSubs, since, accountPubKey).orEmpty() + relayBasedFilters(otherSubs, since, accountPubKey).orEmpty()
}
/**
@@ -258,10 +265,18 @@ object ConcordSubscriptionPlanner {
fun relayBasedFilters(
subs: List<ConcordPlaneSub>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter>? {
val authorsByRelay = HashMap<NormalizedRelayUrl, MutableSet<String>>()
// Which communities each relay is being asked about. Collapsing planes into one filter per
// relay is what makes the subscription screen unable to name them otherwise — the plane
// pubkeys are stream keys, not something a user can recognise.
val communitiesByRelay = HashMap<NormalizedRelayUrl, MutableSet<String>>()
for (sub in subs) {
for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex)
for (relay in sub.relays) {
authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex)
sub.channelId?.let { communitiesByRelay.getOrPut(relay) { HashSet() }.add(it.communityId) }
}
}
if (authorsByRelay.isEmpty()) return null
@@ -271,6 +286,9 @@ object ConcordSubscriptionPlanner {
filter =
ExplainedFilter(
purpose = SubPurpose.COMMUNITY_CHATS,
purposeDetail = "concord live planes",
entityIds = communitiesByRelay[relay]?.sorted(),
accountPubKey = accountPubKey,
// Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the
// live-only typing heartbeats a relay broadcasts but never stores.
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL),
@@ -85,7 +85,7 @@ private class CashuMintDirectorySubAssembler(
val mintAnnouncements =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.MINT_DIRECTORY,
purposeDetail = "mint directory",
kinds = listOf(CashuMintEvent.KIND),
)
@@ -95,7 +95,7 @@ private class CashuMintDirectorySubAssembler(
// recommendations here.
val cashuRecommendations =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.MINT_DIRECTORY,
purposeDetail = "mint directory",
kinds = listOf(MintRecommendationEvent.KIND),
tags = mapOf("k" to listOf(CashuMintEvent.KIND.toString())),
@@ -95,16 +95,31 @@ private class CashuWalletSubAssembler(
) : SingleSubEoseManager<CashuWalletQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun distinct(key: CashuWalletQueryState): Any = key.pubkey
/**
* One set of filters **per account**, never a merged one.
*
* [SingleSubEoseManager] hands over every distinct key, so with two wallets logged in this used
* to take `keys.first().pubkey` while pooling *both* accounts' relays — the second account's
* wallet was never subscribed, and its inbox relays were queried for the first account's
* nutzaps. Keeping each account's pubkey with its own relay sets is also what lets the
* subscription screen attribute these filters instead of piling them under "not attributed".
*/
override fun updateFilter(
keys: List<CashuWalletQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
return keys.flatMap { filtersFor(it, since) }.ifEmpty { null }
}
val pubkey = keys.first().pubkey
val ownEventRelays = keys.flatMap { it.ownEventRelays }.toSet()
val inboxRelays = keys.flatMap { it.inboxRelays }.toSet()
if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return null
private fun filtersFor(
key: CashuWalletQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val pubkey = key.pubkey
val ownEventRelays = key.ownEventRelays
val inboxRelays = key.inboxRelays
if (ownEventRelays.isEmpty() && inboxRelays.isEmpty()) return emptyList()
val ownedFilter =
ExplainedFilter(
@@ -123,13 +138,15 @@ private class CashuWalletSubAssembler(
MintRecommendationEvent.KIND,
),
authors = listOf(pubkey),
accountPubKey = pubkey,
)
val inboundNutzapsFilter =
ExplainedFilter(
purpose = SubPurpose.WALLET,
purpose = SubPurpose.NUTZAP_INBOX,
kinds = listOf(NutzapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
accountPubKey = pubkey,
)
// Own NIP-60 events are read from the user's outbox; inbound nutzaps
@@ -40,6 +40,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
trustedAccounts: List<HexKey>,
relay: NormalizedRelayUrl,
since: Long?,
accountPubKey: HexKey? = null,
): RelayBasedFilter? {
if (targets.isEmpty() || trustedAccounts.isEmpty()) return null
return RelayBasedFilter(
@@ -47,6 +48,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
filter =
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
accountPubKey = accountPubKey,
kinds = ContactCardKindList,
authors = trustedAccounts,
// kind:30382 addresses the target user in the d-tag
@@ -72,6 +74,8 @@ fun filterContactCardsByAuthorInTheRelay(
filter =
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
// This variant fetches an account's OWN contact card, so the author is the owner.
accountPubKey = author,
kinds = ContactCardKindList,
authors = listOf(author),
limit = limit,
@@ -37,6 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
data class MetadataQueryState(
val pubkeys: Set<HexKey>,
val indexRelays: Set<NormalizedRelayUrl>,
/** Which account is looking. Null when the caller has no account context (kept out of attribution). */
val accountPubKey: HexKey? = null,
)
/**
@@ -69,6 +71,11 @@ class MetadataFilterAssembler(
if (allPubkeys.isEmpty() || allRelays.isEmpty()) return null
// Attributed only when one account is looking. These pubkeys are whoever is rendered rather
// than anything an account owns, and several query states are batched into one filter, so with
// more than one asker there is no single honest owner.
val soleAccountPubKey = keys.mapNotNullTo(mutableSetOf()) { it.accountPubKey }.singleOrNull()
val pubkeyList = allPubkeys.toList()
// Create filter for metadata (Kind 0)
@@ -78,6 +85,7 @@ class MetadataFilterAssembler(
authors = pubkeyList,
limit = pubkeyList.size,
purpose = SubPurpose.PROFILE_METADATA,
accountPubKey = soleAccountPubKey,
)
// Apply since times per relay
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
@@ -66,12 +67,18 @@ class ExplainedFilter(
/** Free-form extra context for [SubPurpose.OTHER] or for narrowing a bucket while debugging. */
val purposeDetail: String? = null,
/**
* The thing this filter serves — a community, group, channel or mint id. Deliberately an **id,
* not a name**: names change, are not always loaded when the filter is built, and would pin a
* stale copy into a long-lived subscription. The UI resolves it against `LocalCache` at render
* time, so it always shows the current name and shows nothing gracefully when unknown.
* The things this filter serves — community, group, channel or mint ids.
*
* A **list**, because filters are routinely batched: relay-group state is fetched with one `#d`
* filter per host relay carrying every joined group on it, so a single filter can legitimately
* serve a dozen chats. Modelling one id would have forced either a wrong answer ("All") or a
* filter-per-chat, which is far more REQs than the relays want.
*
* Ids, not names: names change, are often not loaded when the filter is built, and would pin a
* stale copy into a long-lived subscription. The UI resolves them against `LocalCache` at render
* time, so it shows the current name and degrades to a short id when unknown.
*/
val entityId: HexKey? = null,
val entityIds: List<HexKey>? = null,
/**
* Which logged-in account asked for this. Several accounts are commonly active at once and they
* do not share relay sets, so "why is this relay connected" is only answerable per account —
@@ -93,7 +100,7 @@ class ExplainedFilter(
until: Long?,
limit: Int?,
search: String?,
) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityId, accountPubKey)
) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey)
companion object {
/** Tags [filter] with a [purpose], preserving every protocol field. */
@@ -101,7 +108,7 @@ class ExplainedFilter(
filter: Filter,
purpose: SubPurpose,
detail: String? = null,
entityId: HexKey? = null,
entityIds: List<HexKey>? = null,
accountPubKey: HexKey? = null,
) = ExplainedFilter(
filter.ids,
@@ -115,7 +122,7 @@ class ExplainedFilter(
filter.search,
purpose,
detail,
entityId,
entityIds,
accountPubKey,
)
}
@@ -135,8 +142,14 @@ fun Collection<Filter>.purposes(): Set<SubPurpose> = mapNotNullTo(mutableSetOf()
* whose. Entries with no entity collapse to a single row for that purpose.
*/
fun Collection<Filter>.purposeEntities(): Set<PurposeEntity> =
mapNotNullTo(mutableSetOf()) { filter ->
(filter as? ExplainedFilter)?.let { PurposeEntity(it.purpose, it.entityId, it.accountPubKey, it.purposeDetail) }
flatMapTo(mutableSetOf()) { filter ->
val explained = filter as? ExplainedFilter ?: return@flatMapTo emptyList()
val ids = explained.entityIds
if (ids.isNullOrEmpty()) {
listOf(PurposeEntity(explained.purpose, null, explained.accountPubKey, explained.purposeDetail))
} else {
ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKey, explained.purposeDetail) }
}
}
/** A single "this relay is doing X, for Y, on behalf of account Z" fact. Ids only; names resolve in the UI. */
@@ -146,3 +159,23 @@ data class PurposeEntity(
val accountPubKey: HexKey? = null,
val detail: String? = null,
)
/**
* Stamps [accountPubKey] onto every tagged filter that does not already name one.
*
* Applied once where a subscription manager knows its account, rather than threading a pubkey
* parameter through the ~200 filter builders below it. Filters that already name an account are left
* alone, so a builder with better knowledge always wins.
*/
fun List<RelayBasedFilter>.attributedTo(accountPubKey: HexKey): List<RelayBasedFilter> =
map { relayFilter ->
val filter = relayFilter.filter
if (filter is ExplainedFilter && filter.accountPubKey == null) {
RelayBasedFilter(
relay = relayFilter.relay,
filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey),
)
} else {
relayFilter
}
}