mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 5
parent
19a0088e36
commit
a295f31f94
-3
@@ -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() },
|
||||
|
||||
-3
@@ -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,
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
+5
-62
@@ -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)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
-6
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+5
-20
@@ -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() }
|
||||
|
||||
|
||||
+124
@@ -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() }
|
||||
}
|
||||
}
|
||||
-12
@@ -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 =
|
||||
|
||||
+59
-97
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user