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 f1ef8d84d2..d4322fcdae 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 @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus @@ -127,6 +128,9 @@ class RelaySubscriptionsCoordinator( // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) + // active when the wallet's on-chain transactions screen is on top. + val onchainZaps = OnchainZapsFilterAssembler(client) + val all = listOf( account, @@ -167,6 +171,7 @@ class RelaySubscriptionsCoordinator( relayInfoNip66, chess, nwc, + onchainZaps, ) fun destroy() = all.forEach { it.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt index f93cf14b0e..0e15cf8e01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import java.text.NumberFormat @@ -88,6 +89,13 @@ fun OnchainTransactionsScreen( viewModel.fetchTransactions() } + val windowSince by viewModel.oldestBlockTime.collectAsState() + OnchainZapsFilterAssemblerSubscription( + user = accountViewModel.account.userProfile(), + windowSinceSeconds = windowSince, + accountViewModel = accountViewModel, + ) + val transactions by viewModel.filteredTransactions.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val isLoadingMore by viewModel.isLoadingMore.collectAsState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt index dd206db301..23567fc242 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt @@ -31,10 +31,14 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -65,6 +69,8 @@ data class OnchainTxView( } } +private const val SAMPLE_MILLIS = 500L + class OnchainTransactionsViewModel : ViewModel() { private var address: String? = null private var backend: OnchainBackend? = null @@ -113,6 +119,21 @@ class OnchainTransactionsViewModel : ViewModel() { private val _displayAddress = MutableStateFlow(null) val displayAddress = _displayAddress.asStateFlow() + /** + * Oldest `blockTime` (or null = unconfirmed/unknown) across the currently + * loaded chain rows. The screen feeds this into the relay subscription so + * we only ask relays for zap events from that point onwards instead of + * pulling the user's entire NIP-BC history. + * + * Returns null while the page is empty (no constraint — fall back to the + * relay's full history for the first call) or when the oldest row is in + * the mempool (we don't have a timestamp to bound on). + */ + val oldestBlockTime: StateFlow = + chainTxs + .map { txs -> txs.minOfOrNull { it.blockTime ?: Long.MAX_VALUE }?.takeIf { it != Long.MAX_VALUE } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + fun init(accountViewModel: AccountViewModel) { if (address != null) return val pubKey = accountViewModel.account.signer.pubKey @@ -122,10 +143,13 @@ class OnchainTransactionsViewModel : ViewModel() { // Stand up the reactive zap cache. The two filters cover both // directions; LocalCache's filter index narrows the fanout, so we - // only get woken for kind-8333 events that mention us. + // only get woken for kind-8333 events that mention us. .sample(500) + // coalesces bursts so a relay flooding 50 historical zaps at once + // produces at most one UI update. viewModelScope.launch(Dispatchers.IO) { val incoming = Filter(kinds = listOf(OnchainZapEvent.KIND), tags = mapOf("p" to listOf(pubKey))) val outgoing = Filter(kinds = listOf(OnchainZapEvent.KIND), authors = listOf(pubKey)) + @OptIn(FlowPreview::class) combine( LocalCache.observeEvents(incoming), LocalCache.observeEvents(outgoing), @@ -136,7 +160,7 @@ class OnchainTransactionsViewModel : ViewModel() { merged[txid] = z } merged - }.collect { zapsByTxid.value = it } + }.sample(SAMPLE_MILLIS).collect { zapsByTxid.value = it } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt new file mode 100644 index 0000000000..ce9a003f8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt @@ -0,0 +1,137 @@ +/* + * 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.ui.screen.loggedIn.wallet.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +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.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent + +/** + * Per-screen subscription key for the on-chain wallet history. + * + * @property user the logged-in user whose taproot address is being viewed. + * @property windowSinceSeconds lower bound (`since`) for the relay query, in + * seconds. Derived from the oldest visible chain transaction's + * `blockTime` so we only fetch zap events relevant to what the + * screen will actually display. `null` disables the lower bound — + * the relay returns the full history capped by `limit`. + */ +data class OnchainZapsQueryState( + val user: User, + val windowSinceSeconds: Long?, +) + +private const val PER_FILTER_LIMIT = 500 + +/** + * Build per-relay filters for kind-8333 zaps that involve [pubkey] — either + * as the recipient (`p`-tag, incoming) or the author (outgoing). The + * effective `since` is the larger of the visible-window lower bound (so we + * don't drag the whole history every time) and any EOSE-tracked timestamp + * from a previous round (so we only ask the relay for events newer than + * what we already have). + */ +private fun filterOnchainZaps( + pubkey: String, + relays: Collection, + windowSinceSeconds: Long?, + eoseSince: SincePerRelayMap?, +): List = + relays.flatMap { relay -> + val eoseTime = eoseSince?.get(relay)?.time + val since = + listOfNotNull(windowSinceSeconds, eoseTime) + .maxOrNull() + ?.takeIf { it > 0L } + + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(OnchainZapEvent.KIND), + tags = mapOf("p" to listOf(pubkey)), + limit = PER_FILTER_LIMIT, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(OnchainZapEvent.KIND), + authors = listOf(pubkey), + limit = PER_FILTER_LIMIT, + since = since, + ), + ), + ) + } + +class OnchainZapsFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + val key = keys.firstOrNull() ?: return emptyList() + val user = key.user + val relays: Collection = + user.inboxRelays()?.ifEmpty { null } + ?: user.outboxRelays()?.ifEmpty { null } + ?: user.allUsedRelaysOrNull() + ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + if (relays.isEmpty()) return emptyList() + + // Take the tightest window across all live keys — if any subscriber + // is showing older transactions, widen the query to cover them too. + val windowSince = + keys + .mapNotNull { it.windowSinceSeconds } + .minOrNull() + ?.takeIf { keys.all { k -> k.windowSinceSeconds != null } } + + return filterOnchainZaps(user.pubkeyHex, relays, windowSince, since) + } + + override fun distinct(key: OnchainZapsQueryState) = key.user.pubkeyHex +} + +class OnchainZapsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + private val sub = OnchainZapsFilterSubAssembler(client, ::allKeys) + + override fun invalidateFilters() = sub.invalidateFilters() + + override fun invalidateKeys() = invalidateFilters() + + override fun destroy() = sub.destroy() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..1daf4acfe9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssemblerSubscription.kt @@ -0,0 +1,50 @@ +/* + * 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.ui.screen.loggedIn.wallet.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * Lifecycle-aware relay subscription for kind-8333 zaps involving [user], + * bounded by [windowSinceSeconds] (the oldest visible chain transaction's + * blockTime) so the relay only ships zaps that could plausibly attribute to + * what we're showing. + * + * Re-subscribes whenever either input changes — scrolling back into older + * transactions widens the window and re-queries; switching accounts swaps + * the key entirely. + */ +@Composable +fun OnchainZapsFilterAssemblerSubscription( + user: User, + windowSinceSeconds: Long?, + accountViewModel: AccountViewModel, +) { + val state = + remember(user, windowSinceSeconds) { + OnchainZapsQueryState(user, windowSinceSeconds) + } + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().onchainZaps) +}