mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
co-authored by
Claude Opus 5
parent
b6808959cd
commit
4d53bbea9e
+20
-2
@@ -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),
|
||||
|
||||
+2
-2
@@ -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())),
|
||||
|
||||
+22
-5
@@ -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
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+43
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user