diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index 3f2c36b95d..f7ebb414f5 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -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.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, - cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, - subscribedAccounts = { MutableStateFlow(emptySet()) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 38a97dc730..68da55b297 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -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.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, - cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) }, - subscribedAccounts = { MutableStateFlow(emptySet()) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, okHttpClientForMoney = { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8098fed6e7..0dfa486665 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b05ea7e504..05c855facf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -441,9 +441,6 @@ class Account( override val signer: NostrSigner, val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, - val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, - /** Accounts with account-level subscriptions mounted — see [cashuWalletState]. */ - val subscribedAccounts: () -> StateFlow>, 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 9fcbfd7e40..49b9004e68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -59,8 +59,6 @@ import java.io.File class AccountCacheState( val geolocationFlow: () -> StateFlow, val nwcFilterAssembler: () -> NWCPaymentFilterAssembler, - val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler, - val subscribedAccounts: () -> StateFlow>, 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index b2b2b01a61..e18dfbde9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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>, private val outboxRelaysFlow: StateFlow>, private val inboxRelaysFlow: StateFlow>, private val dmRelaysFlow: StateFlow>, @@ -397,7 +388,6 @@ class CashuWalletState( // Lifecycle // ============================================================ private val jobs = mutableListOf() - 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) } // ============================================================ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 653878cd50..a213727a50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 60c502975a..a5f530da31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -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>(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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt new file mode 100644 index 0000000000..2142ffe504 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletEoseManager.kt @@ -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, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 28cda53204..9d372376ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -3004,12 +3004,6 @@ fun mockAccountViewModel(): AccountViewModel { signer = NostrSignerInternal(keyPair), geolocationFlow = { MutableStateFlow(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.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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt index 9542c53a8b..28cf1d9a63 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt @@ -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 = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 78fb9a191d..35ca044868 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -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() { - private val sub = CashuWalletSubAssembler(client, ::allKeys) +fun cashuWalletFilters( + key: CashuWalletQueryState, + since: SincePerRelayMap?, +): List { + 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, -) : SingleSubEoseManager(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, - since: SincePerRelayMap?, - ): List? { - if (keys.isEmpty()) return null - return keys.flatMap { filtersFor(it, since) }.ifEmpty { null } - } - - private fun filtersFor( - key: CashuWalletQueryState, - since: SincePerRelayMap?, - ): List { - 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 }