refactor(relays): mount the Cashu wallet like every other account subscription

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>
This commit is contained in:
Vitor Pamplona
2026-07-31 18:42:25 -04:00
co-authored by Claude Opus 5
parent 19a0088e36
commit a295f31f94
12 changed files with 194 additions and 215 deletions
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
@@ -86,8 +85,6 @@ class NotificationFeedFilterModeOverrideTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
subscribedAccounts = { MutableStateFlow(emptySet()) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -77,8 +76,6 @@ class ThreadDualAxisChartAssemblerTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
subscribedAccounts = { MutableStateFlow(emptySet()) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
@@ -872,8 +872,6 @@ class AppModules(
AccountCacheState(
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
cashuWalletFilterAssembler = { sources.cashuWallet },
subscribedAccounts = { sources.account.subscribedAccounts },
cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory },
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
contentResolverFn = { appContext.contentResolver },
@@ -441,9 +441,6 @@ class Account(
override val signer: NostrSigner,
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
/** Accounts with account-level subscriptions mounted — see [cashuWalletState]. */
val subscribedAccounts: () -> StateFlow<Set<HexKey>>,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val otsResolverBuilder: () -> OtsResolver,
@@ -743,8 +740,6 @@ class Account(
signer = signer,
cache = cache,
scope = scope,
assembler = cashuWalletFilterAssembler(),
subscribedAccounts = subscribedAccounts(),
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
dmRelaysFlow = dmRelays.flow,
@@ -59,8 +59,6 @@ import java.io.File
class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val subscribedAccounts: () -> StateFlow<Set<HexKey>>,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val contentResolverFn: () -> ContentResolver,
@@ -267,8 +265,6 @@ class AccountCacheState(
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
subscribedAccounts = subscribedAccounts,
cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler,
okHttpClientForMoney = okHttpClientForMoney,
otsResolverBuilder = otsResolverBuilder,
@@ -28,8 +28,6 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -102,13 +100,6 @@ class CashuWalletState(
private val signer: NostrSigner,
private val cache: LocalCache,
private val scope: CoroutineScope,
private val assembler: CashuWalletFilterAssembler,
/**
* The accounts whose account-level subscriptions are mounted right now
* (`AccountFilterAssembler.subscribedAccounts`). The wallet follows it so it
* runs for exactly the accounts that pull from relays at all.
*/
private val subscribedAccounts: StateFlow<Set<HexKey>>,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
@@ -397,7 +388,6 @@ class CashuWalletState(
// Lifecycle
// ============================================================
private val jobs = mutableListOf<Job>()
private var currentSubscription: CashuWalletQueryState? = null
@Volatile private var started = false
@@ -475,35 +465,11 @@ class CashuWalletState(
// our own kind:10019 — and another client may have published that
// with relays unrelated to our NIP-65 lists — so we listen on the
// union of those plus our NIP-65 inbox + DM relays.
jobs +=
scope.launch(Dispatchers.IO) {
combine(
subscribedAccounts,
outboxRelaysFlow,
inboxRelaysFlow,
dmRelaysFlow,
_nutzapInfoEvent,
) { subscribed, outbox, inbox, dm, info ->
// Only pull a wallet for an account that is actually subscribed: the
// one on screen, or one the user opted into keeping active in the
// background. Other accounts are held in memory purely so pushed
// gift wraps can be decrypted by their owner — asking relays for
// their wallet put a Wallet and a Nutzap Inbox subscription on the
// wire for every saved account, including accounts that have no
// wallet at all.
if (pubKey !in subscribed) {
null
} else {
CashuWalletQueryState(
pubkey = pubKey,
ownEventRelays = outbox,
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
)
}
}.collect { next ->
if (next == null) clearSubscription() else syncSubscription(next)
}
}
// The relay subscription for this wallet is NOT here. It lives in
// CashuWalletEoseManager, inside the account-level assembler group, so it mounts and
// unmounts with every other account-level loader instead of running for the whole life of
// the Account object. What stays below is wallet *state*: indexing what arrives, and the
// local bookkeeping around it.
// Reactive incremental update: any new event arrival that matches our
// pubkey + the NIP-60/61 kinds we care about gets indexed.
@@ -558,29 +524,6 @@ class CashuWalletState(
fun destroy() {
jobs.forEach { it.cancel() }
jobs.clear()
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
}
// ============================================================
// Subscription management
// ============================================================
private fun clearSubscription() {
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
}
private fun syncSubscription(next: CashuWalletQueryState) {
val previous = currentSubscription
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
clearSubscription()
return
}
if (previous == next) return // unchanged
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = next
assembler.subscribe(next)
}
// ============================================================
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler
@@ -204,10 +203,6 @@ class RelaySubscriptionsCoordinator(
// active when the wallet's on-chain transactions screen is on top.
val onchainZaps = OnchainZapsFilterAssembler(client)
// active when a NIP-60 Cashu wallet exists for the account.
// Subscribes to kinds 17375/7375/7376/7374/10019 by author + inbound 9321 #p=self.
val cashuWallet = CashuWalletFilterAssembler(client)
// active while the user is browsing the NIP-87 mint picker. Subscribes to
// kind:38172 cashu mint announcements + kind:38000 cashu-scoped
// recommendations on the configured relay set.
@@ -279,7 +274,6 @@ class RelaySubscriptionsCoordinator(
chess,
nwc,
onchainZaps,
cashuWallet,
cashuMintDirectory,
)
@@ -32,11 +32,10 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01No
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
// This allows multiple screen to be listening to logged-in accounts.
//
@@ -107,6 +106,9 @@ class AccountFilterAssembler(
notificationsHistory,
// Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay.
NwcNotificationsEoseManager(client, ::preferredKeys),
// NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector
// inside CashuWalletState, so it starts and stops with every other account-level loader.
CashuWalletEoseManager(client, ::preferredKeys),
MarmotGroupEventsEoseManager(client, ::preferredKeys),
)
@@ -128,24 +130,7 @@ class AccountFilterAssembler(
keys.firstOrNull { it.feedContentStates != null } ?: keys.first()
}
private val subscribedAccountsInternal = MutableStateFlow<Set<HexKey>>(emptySet())
/**
* The accounts whose always-on subscriptions are mounted right now, from
* either mount path: a screen's [AccountFilterAssemblerSubscription] or the
* headless [AccountSubscriptionRegistry].
*
* This is the answer to "does this account currently pull from relays?", so
* account-scoped loaders that live outside this assembler — the Cashu wallet
* — can follow it instead of running for every [Account] object that happens
* to be loaded in memory.
*/
val subscribedAccounts = subscribedAccountsInternal.asStateFlow()
override fun invalidateKeys() {
subscribedAccountsInternal.value = allKeys().mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
invalidateFilters()
}
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* The account's NIP-60 wallet and NIP-61 nutzap inbox.
*
* This used to run from a collector inside `CashuWalletState`, on the account's own scope, which made
* the wallet the only account-level subscription whose lifetime was decided by the model rather than
* by a mount. It ran for every [com.vitorpamplona.amethyst.model.Account] object that happened to be
* resident — including accounts loaded purely so pushed gift wraps could be decrypted, which have no
* wallet anyone is looking at — and the attempt to fix that bolted a "is this pubkey subscribed
* anywhere" flow onto the model, so a model object was reading the relay layer's bookkeeping to
* decide whether to talk to relays.
*
* As a manager in [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler]'s
* group it starts and stops with every other account-level loader: the screen's mount for the account
* on show, the registry for the rest, and whatever the foreground/background rule becomes without this
* having to know about it. Exactly how NWC already worked.
*
* Per user, never merged: each account's wallet reads its own outbox for its own events and its own
* inbox for nutzaps addressed to it, so there is no shared query to fold them into.
*/
class CashuWalletEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val account = key.account
val wallet = account.cashuWalletState
// NIP-65 outbox for our own wallet events; inbox + DM relays plus whatever our own kind:10019
// advertises for inbound nutzaps, since another client may have published that with relays
// unrelated to our NIP-65 lists.
return cashuWalletFilters(
CashuWalletQueryState(
pubkey = account.userProfile().pubkeyHex,
ownEventRelays = account.outboxRelays.flow.value,
inboxRelays =
account.notificationRelays.flow.value +
account.dmRelays.flow.value +
(wallet.nutzapInfoEvent.value?.relays() ?: emptyList()),
),
since,
)
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
// The relay sets and the nutzap-info event all move the query, so each one re-invalidates.
// Sampled because a relay-list edit can land as a burst of list events.
userJobMap[user] =
listOf(
key.account.outboxRelays.flow,
key.account.notificationRelays.flow,
key.account.dmRelays.flow,
).map { flow ->
key.account.scope.launch(Dispatchers.IO) {
flow.sample(1000).collectLatest { invalidateFilters() }
}
} +
listOf(
key.account.scope.launch(Dispatchers.IO) {
key.account.cashuWalletState.nutzapInfoEvent
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap.remove(key)?.forEach { it.cancel() }
}
}
@@ -3004,12 +3004,6 @@ fun mockAccountViewModel(): AccountViewModel {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { nwcFilters },
cashuWalletFilterAssembler = {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuWalletFilterAssembler(client)
},
// A mock account is always "on screen", so its wallet renders in previews.
subscribedAccounts = { MutableStateFlow(setOf(keyPair.pubKey.toHexKey())) },
cashuMintDirectoryFilterAssembler = {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuMintDirectoryFilterAssembler(client)
@@ -3066,12 +3060,6 @@ fun mockVitorAccountViewModel(): AccountViewModel {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { nwcFilters },
cashuWalletFilterAssembler = {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuWalletFilterAssembler(client)
},
// A mock account is always "on screen", so its wallet renders in previews.
subscribedAccounts = { MutableStateFlow(setOf(keyPair.pubKey.toHexKey())) },
cashuMintDirectoryFilterAssembler = {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuMintDirectoryFilterAssembler(client)
@@ -100,7 +100,7 @@ class CashuContext(
*/
suspend fun snapshot(): CashuWalletReader.WalletSnapshot {
val pk = ctx.identity.pubKeyHex
// Mirror commons' CashuWalletFilterAssembler exactly: authored wallet
// Mirror commons' cashuWalletFilters exactly: authored wallet
// kinds by authors=[pk], inbound nutzaps by #p — so amy projects the
// same event set the Android app subscribes to.
val authored =
@@ -21,14 +21,10 @@
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
@@ -63,8 +59,7 @@ data class CashuWalletQueryState(
)
/**
* Subscribes to all NIP-60 / NIP-61 events that participate in this
* account's Cashu wallet:
* Every NIP-60 / NIP-61 filter for one account's Cashu wallet:
*
* - kind 17375 — the wallet event (replaceable)
* - kind 7375 — unspent proofs (token events)
@@ -73,102 +68,69 @@ data class CashuWalletQueryState(
* - kind 10019 — nutzap info (replaceable, for incoming nutzaps)
* - kind 9321 — inbound nutzaps tagged with the user's pubkey
*
* All authored events are queried by `authors=[pubkey]`; nutzaps are queried by
* `#p=[pubkey]` (we receive them, not send them, from this filter's perspective).
* Authored events are queried by `authors=[pubkey]`; nutzaps by `#p=[pubkey]`, since we receive
* those rather than send them.
*
* A plain function rather than a subscription manager: the wallet's lifetime belongs to the account,
* so it is mounted by the account-level assembler alongside notifications, DMs and NWC, and this only
* has to describe the query.
*/
@Stable
class CashuWalletFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<CashuWalletQueryState>() {
private val sub = CashuWalletSubAssembler(client, ::allKeys)
fun cashuWalletFilters(
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()
override fun invalidateFilters() = sub.invalidateFilters()
val ownedFilter =
ExplainedFilter(
purpose = SubPurpose.WALLET,
kinds =
listOf(
CashuWalletEvent.KIND,
CashuTokenEvent.KIND,
CashuSpendingHistoryEvent.KIND,
CashuMintQuoteEvent.KIND,
NutzapInfoEvent.KIND,
// NIP-87 mint recommendations the user has published.
// Pulled here (instead of relying on the general
// account filter) so the Cashu Settings screen can
// list and retract them without any extra subscription.
MintRecommendationEvent.KIND,
),
authors = listOf(pubkey),
accountPubKeys = listOfNotNull(pubkey),
)
override fun invalidateKeys() = invalidateFilters()
val inboundNutzapsFilter =
ExplainedFilter(
purpose = SubPurpose.NUTZAP_INBOX,
kinds = listOf(NutzapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
accountPubKeys = listOfNotNull(pubkey),
)
override fun destroy() = sub.destroy()
}
private class CashuWalletSubAssembler(
client: INostrClient,
allKeys: () -> Set<CashuWalletQueryState>,
) : 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 }
}
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(
purpose = SubPurpose.WALLET,
kinds =
listOf(
CashuWalletEvent.KIND,
CashuTokenEvent.KIND,
CashuSpendingHistoryEvent.KIND,
CashuMintQuoteEvent.KIND,
NutzapInfoEvent.KIND,
// NIP-87 mint recommendations the user has published.
// Pulled here (instead of relying on the general
// account filter) so the Cashu Settings screen can
// list and retract them without any extra subscription.
MintRecommendationEvent.KIND,
),
authors = listOf(pubkey),
accountPubKeys = listOfNotNull(pubkey),
// Own NIP-60 events are read from the user's outbox; inbound nutzaps
// from the user's inbox set. A relay that appears in both gets both
// filters.
val ownedSubs =
ownEventRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
RelayBasedFilter(
relay,
if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter,
)
val inboundNutzapsFilter =
ExplainedFilter(
purpose = SubPurpose.NUTZAP_INBOX,
kinds = listOf(NutzapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
accountPubKeys = listOfNotNull(pubkey),
}
val inboundSubs =
inboxRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
RelayBasedFilter(
relay,
if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter,
)
}
// Own NIP-60 events are read from the user's outbox; inbound nutzaps
// from the user's inbox set. A relay that appears in both gets both
// filters.
val ownedSubs =
ownEventRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
RelayBasedFilter(
relay,
if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter,
)
}
val inboundSubs =
inboxRelays.map { relay ->
val sinceTime = since?.get(relay)?.time
RelayBasedFilter(
relay,
if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter,
)
}
return ownedSubs + inboundSubs
}
return ownedSubs + inboundSubs
}