From 8ffe1aee4fca5e90fbfda4a9956858f6c301d9ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 22:19:47 +0000 Subject: [PATCH 1/5] feat(wallet): clickable onchain card opens transaction history Tapping the Bitcoin card on the wallet screen now navigates to a new OnchainTransactionsScreen that lists transactions touching the account's Taproot address, mirroring the NWC transactions view. - OnchainBackend gains getTxsForAddress(address, afterTxid) returning BitcoinAddressTx rows (netValueSats, confirmations, blockHeight, blockTime, counterparty addresses). EsploraBackend implements it via GET /address/{addr}/txs and /address/{addr}/txs/chain/{last_seen} for pagination; CachingOnchainBackend passes through. - OnchainTransactionsViewModel loads the address from the account signer + LocalCache.onchainBackend, paginates, and for each chain row scans LocalCache for an OnchainZapEvent with a matching txid so the UI can render the Nostr counterparty (sender pubkey for incoming, p-tagged recipient for outgoing). - ALL / ZAPS / NON-ZAPS filter chips reuse the existing TransactionFilter enum. Mempool rows are flagged "Pending" in bitcoin-orange. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../screen/loggedIn/wallet/OnchainSection.kt | 9 +- .../wallet/OnchainTransactionsScreen.kt | 415 ++++++++++++++++++ .../wallet/OnchainTransactionsViewModel.kt | 185 ++++++++ .../ui/screen/loggedIn/wallet/WalletScreen.kt | 1 + amethyst/src/main/res/values/strings.xml | 4 + .../commons/onchain/OnchainZapSenderTest.kt | 6 + .../nipBCOnchainZaps/chain/BitcoinTx.kt | 33 ++ .../chain/CachingOnchainBackend.kt | 5 + .../nipBCOnchainZaps/chain/OnchainBackend.kt | 14 + .../chain/CachingOnchainBackendTest.kt | 5 + .../verify/OnchainZapVerifierTest.kt | 6 + .../nipBCOnchainZaps/chain/EsploraBackend.kt | 85 ++++ .../chain/EsploraBackendTest.kt | 56 +++ 15 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 04055ec77..96fba2f40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -178,6 +178,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen @@ -266,6 +267,7 @@ fun BuildNavigation( composableFromEnd { WalletSendScreen(accountViewModel, nav) } composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + composableFromEnd { OnchainTransactionsScreen(accountViewModel, nav) } composableFromEndArgs { WalletDetailScreen(it.walletId, accountViewModel, nav) } composableFromEnd { AddWalletScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3d5ece622..322da5cb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -111,6 +111,8 @@ sealed class Route { @Serializable object WalletTransactions : Route() + @Serializable object OnchainTransactions : Route() + @Serializable data class WalletDetail( val walletId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt index 490672ed9..e6eb069f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -63,6 +64,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress @@ -83,6 +86,7 @@ import java.text.NumberFormat @Composable fun OnchainSection( accountViewModel: AccountViewModel, + nav: INav, modifier: Modifier = Modifier, ) { val pubKey = accountViewModel.account.signer.pubKey @@ -118,7 +122,10 @@ fun OnchainSection( val orange = MaterialTheme.colorScheme.bitcoinColor Card( - modifier = modifier.fillMaxWidth(), + modifier = + modifier + .fillMaxWidth() + .clickable { nav.nav(Route.OnchainTransactions) }, shape = RoundedCornerShape(16.dp), border = BorderStroke(2.dp, orange), colors = 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 new file mode 100644 index 000000000..28006b038 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -0,0 +1,415 @@ +/* + * 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 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +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.stringRes +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.absoluteValue + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnchainTransactionsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: OnchainTransactionsViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + viewModel.init(accountViewModel) + viewModel.fetchTransactions() + } + + val transactions by viewModel.filteredTransactions.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val isLoadingMore by viewModel.isLoadingMore.collectAsState() + val hasMore by viewModel.hasMoreTransactions.collectAsState() + val currentFilter by viewModel.transactionFilter.collectAsState() + val address by viewModel.displayAddress.collectAsState() + val error by viewModel.error.collectAsState() + + val listState = rememberLazyListState() + + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 5 && !isLoadingMore && hasMore && transactions.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + viewModel.loadMoreTransactions() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_onchain_transactions)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { viewModel.fetchTransactions() }) { + Icon( + symbol = MaterialSymbols.Refresh, + contentDescription = stringRes(R.string.wallet_refresh), + ) + } + }, + ) + }, + ) { padding -> + when { + address == null -> { + EmptyMessage(padding, stringRes(R.string.wallet_onchain_no_address)) + } + isLoading && transactions.isEmpty() -> { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_loading), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + error != null && transactions.isEmpty() -> { + EmptyMessage( + padding, + error ?: stringRes(R.string.wallet_onchain_no_backend), + ) + } + transactions.isEmpty() -> { + EmptyMessage(padding, stringRes(R.string.wallet_no_transactions)) + } + else -> { + LazyColumn( + modifier = Modifier.padding(padding), + state = listState, + ) { + item { AddressHeader(address) } + item { + TransactionFilterRow(currentFilter) { viewModel.setTransactionFilter(it) } + } + items(transactions, key = { it.tx.txid }) { txView -> + OnchainTransactionItem(txView, accountViewModel, nav) + HorizontalDivider() + } + if (isLoadingMore) { + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + } + } + } +} + +@Composable +private fun EmptyMessage( + padding: androidx.compose.foundation.layout.PaddingValues, + message: String, +) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun AddressHeader(address: String?) { + if (address.isNullOrBlank()) return + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = address, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TransactionFilterRow( + currentFilter: TransactionFilter, + onFilterSelected: (TransactionFilter) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = currentFilter == TransactionFilter.ALL, + onClick = { onFilterSelected(TransactionFilter.ALL) }, + label = { Text(stringRes(R.string.wallet_filter_all)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.ZAPS, + onClick = { onFilterSelected(TransactionFilter.ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_zaps)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.NON_ZAPS, + onClick = { onFilterSelected(TransactionFilter.NON_ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_non_zaps)) }, + ) + } +} + +@Composable +private fun OnchainTransactionItem( + view: OnchainTxView, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isIncoming = view.isIncoming + val amountSats = view.tx.netValueSats.absoluteValue + val formattedAmount = + remember(view.tx.netValueSats) { + val fmt = NumberFormat.getIntegerInstance() + (if (isIncoming) "+" else "-") + fmt.format(amountSats) + } + + val dateText = + remember(view.tx.blockTime, view.tx.confirmations) { + val ts = view.tx.blockTime + if (ts != null) { + SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(ts * 1000L)) + } else { + "" + } + } + + val counterpartyPubkeyHex = view.counterpartyPubkeyHex() + val counterpartyAddress = view.tx.counterpartyAddresses.firstOrNull() + val zapComment = view.zap?.content?.takeIf { it.isNotBlank() } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (counterpartyPubkeyHex != null) { + UserPicture( + userHex = counterpartyPubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = Modifier.width(12.dp)) + } else { + Icon( + symbol = if (isIncoming) MaterialSymbols.ArrowDownward else MaterialSymbols.ArrowUpward, + contentDescription = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + modifier = Modifier.size(40.dp), + tint = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + if (counterpartyPubkeyHex != null) { + OnchainCounterpartyName(counterpartyPubkeyHex, accountViewModel) + } else if (counterpartyAddress != null) { + Text( + text = counterpartyAddress, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (zapComment != null) { + Text( + text = zapComment, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = dateText.ifBlank { stringRes(R.string.wallet_onchain_pending) }, + style = MaterialTheme.typography.bodySmall, + color = + if (view.tx.confirmations == 0) { + MaterialTheme.colorScheme.bitcoinColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Text( + text = "$formattedAmount sats", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } +} + +@Composable +private fun OnchainCounterpartyName( + pubkeyHex: String, + accountViewModel: AccountViewModel, +) { + LoadUser(baseUserHex = pubkeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Medium, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = pubkeyHex.take(8) + "...", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} 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 new file mode 100644 index 000000000..6de440565 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt @@ -0,0 +1,185 @@ +/* + * 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 + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinAddressTx +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * One row in the on-chain transactions list. Combines the chain-level view of + * a transaction ([tx]) with an optional [OnchainZapEvent] from `LocalCache` + * that references the same txid — the zap event is what lets us identify the + * Nostr counterparty (sender for incoming, recipient for outgoing) the same + * way [WalletTransactionsScreen] does for NWC zaps via embedded zap requests. + */ +@Immutable +data class OnchainTxView( + val tx: BitcoinAddressTx, + val zap: OnchainZapEvent?, +) { + val isIncoming: Boolean get() = tx.netValueSats >= 0L + + /** + * Nostr pubkey of the counterparty if known. For an incoming transaction + * the zap event is signed by the sender (so [OnchainZapEvent.pubKey] is + * the sender). For an outgoing transaction the counterparty is the zap's + * `p`-tagged recipient. + */ + fun counterpartyPubkeyHex(): String? = + zap?.let { z -> + if (isIncoming) z.pubKey else z.recipient() + } +} + +class OnchainTransactionsViewModel : ViewModel() { + private var address: String? = null + private var backend: OnchainBackend? = null + + /** Most recent confirmed txid we've already fetched; null until we've loaded a page. */ + private var lastSeenTxid: String? = null + + private val allTransactions = MutableStateFlow>(emptyList()) + + private val _transactionFilter = MutableStateFlow(TransactionFilter.ALL) + val transactionFilter = _transactionFilter.asStateFlow() + + val filteredTransactions = + combine(allTransactions, _transactionFilter) { txs, filter -> + when (filter) { + TransactionFilter.ALL -> txs + TransactionFilter.ZAPS -> txs.filter { it.zap != null } + TransactionFilter.NON_ZAPS -> txs.filter { it.zap == null } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore = _isLoadingMore.asStateFlow() + + private val _hasMoreTransactions = MutableStateFlow(true) + val hasMoreTransactions = _hasMoreTransactions.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + /** The Taproot address being displayed, exposed for the UI header. */ + private val _displayAddress = MutableStateFlow(null) + val displayAddress = _displayAddress.asStateFlow() + + fun init(accountViewModel: AccountViewModel) { + if (address != null) return + val pubKey = accountViewModel.account.signer.pubKey + address = runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() + backend = LocalCache.onchainBackend + _displayAddress.value = address + } + + fun setTransactionFilter(filter: TransactionFilter) { + _transactionFilter.value = filter + } + + fun fetchTransactions() { + val addr = address ?: return + val be = backend ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _error.value = null + lastSeenTxid = null + _hasMoreTransactions.value = true + try { + val rows = withContext(Dispatchers.IO) { be.getTxsForAddress(addr, null) } + val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } + allTransactions.value = views + lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid + _hasMoreTransactions.value = lastSeenTxid != null + } catch (t: Throwable) { + _error.value = t.message ?: "Failed to load transactions" + _hasMoreTransactions.value = false + } finally { + _isLoading.value = false + } + } + } + + fun loadMoreTransactions() { + if (_isLoadingMore.value || !_hasMoreTransactions.value) return + val addr = address ?: return + val be = backend ?: return + val seen = lastSeenTxid ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoadingMore.value = true + try { + val rows = withContext(Dispatchers.IO) { be.getTxsForAddress(addr, seen) } + if (rows.isEmpty()) { + _hasMoreTransactions.value = false + } else { + val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } + allTransactions.value = allTransactions.value + views + lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid ?: seen + } + } catch (t: Throwable) { + _error.value = t.message ?: "Failed to load more transactions" + } finally { + _isLoadingMore.value = false + } + } + } + + fun clearError() { + _error.value = null + } + + /** + * Scan `LocalCache.notes` for the [OnchainZapEvent] that references this + * txid. NIP-BC is anti-spoofing-checked on consume, so any event we find + * here has already been accepted as authentic and matches our chain row. + * There's no txid-indexed map yet — the scan stays acceptable while + * on-chain zap volume is small. + */ + private fun findZapForTxid(txid: String): OnchainZapEvent? { + var found: OnchainZapEvent? = null + LocalCache.notes.forEach { _, note -> + if (found != null) return@forEach + val ev = note.event + if (ev is OnchainZapEvent && ev.txid() == txid) { + found = ev + } + } + return found + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 164669436..0b36f5325 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -127,6 +127,7 @@ fun WalletScreen( Column(modifier = Modifier.padding(padding)) { OnchainSection( accountViewModel = accountViewModel, + nav = nav, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) if (!hasWallet) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4dcea91c..1d721464c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1837,6 +1837,10 @@ Invalid NWC connection URI Move Up Move Down + Onchain Transactions + No on-chain address available for this account. + No chain backend is configured. + Pending Security Filters Import Follows diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt index 3f00933cd..85849ff0c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinAddressTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend @@ -69,6 +70,11 @@ class OnchainZapSenderTest { override suspend fun getUtxosForAddress(address: String): List = utxos + override suspend fun getTxsForAddress( + address: String, + afterTxid: String?, + ): List = emptyList() + override suspend fun broadcast(rawTxHex: String): String { if (broadcastFails) throw RuntimeException("relay rejected tx") broadcastedHex = rawTxHex diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt index f492bfee8..e2a5f3812 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/BitcoinTx.kt @@ -80,3 +80,36 @@ data class FeeEstimates( val normalSatPerVbyte: Double, val slowSatPerVbyte: Double, ) + +/** + * A transaction projected onto a single Bitcoin address — the view a wallet UI + * needs for a transaction history list. + * + * `netValueSats` is the address-perspective delta: positive means the address + * received sats in this tx (inputs from the address < outputs to the address), + * negative means it sent sats (the typical sender pays change back to itself, + * so this is `outputs_to_others_or_zero - inputs_from_address`). It is the + * post-fee delta only when the address is a sender (fees come out of inputs); + * for a pure receiver, fees are invisible. + * + * `counterpartyAddresses` is a best-effort short list of addresses on the + * other side: for an incoming tx, distinct addresses across the inputs; for an + * outgoing tx, distinct output addresses that are NOT [address]. Empty when the + * backend can't derive an address (non-standard scripts, missing prevout). + * + * @property txid 64-char lowercase hex transaction id + * @property netValueSats Net effect on the address (+ received, − sent) + * @property confirmations 0 for mempool; ≥1 for confirmed + * @property blockHeight Height of the confirming block, if any + * @property blockTime Unix seconds of the confirming block, if any + * @property counterpartyAddresses Best-effort other-side addresses + */ +@Immutable +data class BitcoinAddressTx( + val txid: String, + val netValueSats: Long, + val confirmations: Int, + val blockHeight: Long? = null, + val blockTime: Long? = null, + val counterpartyAddresses: List = emptyList(), +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt index 3ca48b80e..9daf0a1a5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt @@ -85,6 +85,11 @@ class CachingOnchainBackend( override suspend fun getUtxosForAddress(address: String): List = delegate.getUtxosForAddress(address) + override suspend fun getTxsForAddress( + address: String, + afterTxid: String?, + ): List = delegate.getTxsForAddress(address, afterTxid) + override suspend fun broadcast(rawTxHex: String): String = delegate.broadcast(rawTxHex) override suspend fun tipHeight(): Long { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt index 8610ea146..14621eb25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/OnchainBackend.kt @@ -33,6 +33,20 @@ interface OnchainBackend { /** Fetch the spendable UTXOs paying `address` (bech32m taproot for NIP-BC). */ suspend fun getUtxosForAddress(address: String): List + /** + * Fetch a page of transactions touching `address`, projected onto the + * address's perspective (see [BitcoinAddressTx.netValueSats]). + * + * The first call (`afterTxid == null`) returns the most recent mempool + * transactions followed by the first page of confirmed transactions, newest + * first. Subsequent calls pass the txid of the last entry of the previous + * confirmed page to get the next page. An empty list means no more pages. + */ + suspend fun getTxsForAddress( + address: String, + afterTxid: String? = null, + ): List + /** Broadcast a fully signed transaction (lowercase hex). Returns the txid. */ suspend fun broadcast(rawTxHex: String): String diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt index e7ca67e7c..6b015a57a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt @@ -41,6 +41,11 @@ class CachingOnchainBackendTest { override suspend fun getUtxosForAddress(address: String): List = emptyList() + override suspend fun getTxsForAddress( + address: String, + afterTxid: String?, + ): List = emptyList() + override suspend fun broadcast(rawTxHex: String): String = "broadcast" override suspend fun tipHeight(): Long { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt index d6fab46b7..adf4290e9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/verify/OnchainZapVerifierTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nipBCOnchainZaps.verify import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinAddressTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTxOutput import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates @@ -83,6 +84,11 @@ class OnchainZapVerifierTest { override suspend fun getUtxosForAddress(address: String): List = emptyList() + override suspend fun getTxsForAddress( + address: String, + afterTxid: String?, + ): List = emptyList() + override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException() override suspend fun tipHeight(): Long = tip diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt index d2058cb73..d339c3fb5 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackend.kt @@ -94,6 +94,35 @@ class EsploraBackend( } } + override suspend fun getTxsForAddress( + address: String, + afterTxid: String?, + ): List { + // Esplora returns mempool + most-recent confirmed for the first call, + // and successive pages of confirmed via /chain/:last_seen_txid. + val url = + if (afterTxid == null) { + "${baseUrl()}/address/$address/txs" + } else { + "${baseUrl()}/address/$address/txs/chain/$afterTxid" + } + val request = + Request + .Builder() + .header("Accept", "application/json") + .url(url) + .get() + .build() + return client.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw OnchainBackendException( + "GET $url failed: ${response.code} ${response.message}", + ) + } + parseAddressTxList(response.body.string(), address) + } + } + override suspend fun broadcast(rawTxHex: String): String { val url = "${baseUrl()}/tx" val request = @@ -218,6 +247,62 @@ class EsploraBackend( ) } + /** + * Parse an Esplora `/address/:addr/txs[...] `response — an array of full + * tx objects with vin/vout/status — into address-perspective rows. + */ + internal fun parseAddressTxList( + json: String, + address: String, + ): List { + val node = JacksonMapper.mapper.readTree(json) + return node.map { tx -> + val txid = tx["txid"].asText() + val status = tx["status"] + val confirmed = status?.get("confirmed")?.asBoolean() == true + val blockHeight = status?.get("block_height")?.asLong() + val blockTime = status?.get("block_time")?.asLong() + + var spentByMe = 0L + val incomingCounterparties = LinkedHashSet() + tx["vin"]?.forEach { vin -> + val prevout = vin["prevout"] + val prevAddress = prevout?.get("scriptpubkey_address")?.asText() + val value = prevout?.get("value")?.asLong() ?: 0L + if (prevAddress == address) { + spentByMe += value + } else if (prevAddress != null) { + incomingCounterparties.add(prevAddress) + } + } + + var receivedByMe = 0L + val outgoingCounterparties = LinkedHashSet() + tx["vout"]?.forEach { vout -> + val outAddress = vout["scriptpubkey_address"]?.asText() + val value = vout["value"]?.asLong() ?: 0L + if (outAddress == address) { + receivedByMe += value + } else if (outAddress != null) { + outgoingCounterparties.add(outAddress) + } + } + + val net = receivedByMe - spentByMe + val counterparties = + if (net >= 0L) incomingCounterparties.toList() else outgoingCounterparties.toList() + + BitcoinAddressTx( + txid = txid, + netValueSats = net, + confirmations = if (confirmed) 1 else 0, + blockHeight = blockHeight, + blockTime = blockTime, + counterpartyAddresses = counterparties, + ) + } + } + internal fun parseUtxoList(json: String): List { val node = JacksonMapper.mapper.readTree(json) return node.map { utxo -> diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt index 77b41dabb..b7ee3cd1b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/EsploraBackendTest.kt @@ -108,6 +108,62 @@ class EsploraBackendTest { assertEquals(0, utxos[1].confirmations, "unconfirmed UTXO must report 0 confirmations") } + @Test + fun parsesAddressTxList_incomingAndOutgoing() { + // Two transactions: + // - tx1: pays 25_000 to our address (incoming, sender = bc1qSENDER) + // - tx2: spends 100_000 from our address; 30_000 to bc1qOUT and 65_000 back as change (outgoing) + val ours = "bc1pours" + val json = + """ + [ + { + "txid": "1111111111111111111111111111111111111111111111111111111111111111", + "vin": [ + { "prevout": { "scriptpubkey_address": "bc1qsender", "value": 50000 } } + ], + "vout": [ + { "scriptpubkey_address": "$ours", "value": 25000 }, + { "scriptpubkey_address": "bc1qsender", "value": 24500 } + ], + "status": { "confirmed": true, "block_height": 800000, "block_time": 1700000000 } + }, + { + "txid": "2222222222222222222222222222222222222222222222222222222222222222", + "vin": [ + { "prevout": { "scriptpubkey_address": "$ours", "value": 100000 } } + ], + "vout": [ + { "scriptpubkey_address": "bc1qout", "value": 30000 }, + { "scriptpubkey_address": "$ours", "value": 65000 } + ], + "status": { "confirmed": false } + } + ] + """.trimIndent() + + val txs = backend.parseAddressTxList(json, ours) + assertEquals(2, txs.size) + + // tx1: incoming, +25_000 sats. + assertEquals(25_000L, txs[0].netValueSats) + assertEquals(1, txs[0].confirmations) + assertEquals(800_000L, txs[0].blockHeight) + assertEquals(1_700_000_000L, txs[0].blockTime) + assertEquals(listOf("bc1qsender"), txs[0].counterpartyAddresses) + + // tx2: outgoing, net = 65_000 - 100_000 = -35_000 (includes fee). + assertEquals(-35_000L, txs[1].netValueSats) + assertEquals(0, txs[1].confirmations) + assertEquals(listOf("bc1qout"), txs[1].counterpartyAddresses) + } + + @Test + fun parsesAddressTxList_emptyResponse() { + val txs = backend.parseAddressTxList("[]", "bc1pignored") + assertEquals(0, txs.size) + } + @Test fun parsesMempoolSpaceRecommendedFees() { // mempool.space /v1/fees/recommended From acf3daaf75a944d7a970028f64549b89df747d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:54:04 +0000 Subject: [PATCH 2/5] feat(wallet): tappable onchain rows + txid index in LocalCache - LocalCache now keeps a ConcurrentHashMap populated in consume(OnchainZapEvent). First sender to claim a txid wins. The on-chain transactions ViewModel switches to LocalCache.getOnchainZapByTxid(txid), replacing the per-row scan of notes. - Each row in OnchainTransactionsScreen is now clickable: rows with a matched zap event navigate to the zap's note thread; the rest open the transaction on mempool.space in the system browser. --- .../amethyst/model/LocalCache.kt | 18 ++++++++++ .../wallet/OnchainTransactionsScreen.kt | 35 ++++++++++++++++++- .../wallet/OnchainTransactionsViewModel.kt | 23 ++---------- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d60787d01..7ba613c8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -271,6 +271,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.SortedSet +import java.util.concurrent.ConcurrentHashMap interface ILocalCache { fun markAsSeen( @@ -295,6 +296,18 @@ object LocalCache : ILocalCache, ICacheProvider { val paymentTracker = NwcPaymentTracker() + /** + * Index from on-chain transaction id → the first [OnchainZapEvent] we + * accepted that claims it. Populated in [consume]`(OnchainZapEvent)` after + * the event passes verification. The wallet's on-chain history view uses + * it to attribute a Nostr sender/recipient to a chain row without scanning + * [notes] for every transaction. + */ + private val onchainZapsByTxid = ConcurrentHashMap() + + /** Returns the cached [OnchainZapEvent] for [txid], if any. */ + fun getOnchainZapByTxid(txid: String): OnchainZapEvent? = onchainZapsByTxid[txid] + /** * Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps * against the actual on-chain transaction. `null` disables verification (incoming @@ -1761,6 +1774,11 @@ object LocalCache : ILocalCache, ICacheProvider { note.loadEvent(event, author, repliesTo) refreshNewNoteObservers(note) + // First sender to claim a txid wins the index — duplicates are rare + // (the verifier proves the tx pays the recipient) and keeping a single + // attribution matches what the on-chain wallet UI shows. + event.txid()?.let { onchainZapsByTxid.putIfAbsent(it, event) } + // Verification needs a chain backend. Without one (e.g. before Account // wires its EsploraBackend) the event is still cached so subscriptions // and profile zap views see it, but it can't contribute to Note totals. 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 28006b038..f93cf14b0 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,6 +51,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -165,6 +169,7 @@ fun OnchainTransactionsScreen( EmptyMessage(padding, stringRes(R.string.wallet_no_transactions)) } else -> { + val uriHandler = LocalUriHandler.current LazyColumn( modifier = Modifier.padding(padding), state = listState, @@ -174,7 +179,12 @@ fun OnchainTransactionsScreen( TransactionFilterRow(currentFilter) { viewModel.setTransactionFilter(it) } } items(transactions, key = { it.tx.txid }) { txView -> - OnchainTransactionItem(txView, accountViewModel, nav) + OnchainTransactionItem( + view = txView, + accountViewModel = accountViewModel, + nav = nav, + onClick = { handleTxClick(txView, nav, uriHandler) }, + ) HorizontalDivider() } if (isLoadingMore) { @@ -268,6 +278,7 @@ private fun OnchainTransactionItem( view: OnchainTxView, accountViewModel: AccountViewModel, nav: INav, + onClick: () -> Unit, ) { val isIncoming = view.isIncoming val amountSats = view.tx.netValueSats.absoluteValue @@ -295,6 +306,7 @@ private fun OnchainTransactionItem( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -390,6 +402,27 @@ private fun OnchainTransactionItem( } } +/** + * Dispatch a transaction-row tap: jump to the matched on-chain zap event's + * thread when we have one, otherwise open the tx on a public block explorer. + * Mempool.space works over Tor and clearnet; deriving the user's configured + * explorer URL would also need to know whether Bitcoin traffic is being + * routed over Tor right now, which the UI layer doesn't carry — stick with + * mempool.space as a sensible default. + */ +private fun handleTxClick( + view: OnchainTxView, + nav: INav, + uriHandler: UriHandler, +) { + val zap = view.zap + if (zap != null) { + nav.nav(Route.Note(zap.id)) + } else { + runCatching { uriHandler.openUri("https://mempool.space/tx/${view.tx.txid}") } + } +} + @Composable private fun OnchainCounterpartyName( pubkeyHex: String, 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 6de440565..4d9b372dd 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 @@ -123,7 +123,7 @@ class OnchainTransactionsViewModel : ViewModel() { _hasMoreTransactions.value = true try { val rows = withContext(Dispatchers.IO) { be.getTxsForAddress(addr, null) } - val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } + val views = rows.map { OnchainTxView(it, LocalCache.getOnchainZapByTxid(it.txid)) } allTransactions.value = views lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid _hasMoreTransactions.value = lastSeenTxid != null @@ -148,7 +148,7 @@ class OnchainTransactionsViewModel : ViewModel() { if (rows.isEmpty()) { _hasMoreTransactions.value = false } else { - val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } + val views = rows.map { OnchainTxView(it, LocalCache.getOnchainZapByTxid(it.txid)) } allTransactions.value = allTransactions.value + views lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid ?: seen } @@ -163,23 +163,4 @@ class OnchainTransactionsViewModel : ViewModel() { fun clearError() { _error.value = null } - - /** - * Scan `LocalCache.notes` for the [OnchainZapEvent] that references this - * txid. NIP-BC is anti-spoofing-checked on consume, so any event we find - * here has already been accepted as authentic and matches our chain row. - * There's no txid-indexed map yet — the scan stays acceptable while - * on-chain zap volume is small. - */ - private fun findZapForTxid(txid: String): OnchainZapEvent? { - var found: OnchainZapEvent? = null - LocalCache.notes.forEach { _, note -> - if (found != null) return@forEach - val ev = note.event - if (ev is OnchainZapEvent && ev.txid() == txid) { - found = ev - } - } - return found - } } From e977b96ee6411b89fb842b7b740080a73fb923e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:10:53 +0000 Subject: [PATCH 3/5] revert(wallet): drop onchain txid index, scan notes instead The dedicated ConcurrentHashMap in LocalCache didn't pay for itself at current NIP-BC volumes. The on-chain transactions view goes back to scanning LocalCache.notes for the matching OnchainZapEvent per row. --- .../amethyst/model/LocalCache.kt | 18 ---------------- .../wallet/OnchainTransactionsViewModel.kt | 21 +++++++++++++++++-- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7ba613c8b..d60787d01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -271,7 +271,6 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.SortedSet -import java.util.concurrent.ConcurrentHashMap interface ILocalCache { fun markAsSeen( @@ -296,18 +295,6 @@ object LocalCache : ILocalCache, ICacheProvider { val paymentTracker = NwcPaymentTracker() - /** - * Index from on-chain transaction id → the first [OnchainZapEvent] we - * accepted that claims it. Populated in [consume]`(OnchainZapEvent)` after - * the event passes verification. The wallet's on-chain history view uses - * it to attribute a Nostr sender/recipient to a chain row without scanning - * [notes] for every transaction. - */ - private val onchainZapsByTxid = ConcurrentHashMap() - - /** Returns the cached [OnchainZapEvent] for [txid], if any. */ - fun getOnchainZapByTxid(txid: String): OnchainZapEvent? = onchainZapsByTxid[txid] - /** * Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps * against the actual on-chain transaction. `null` disables verification (incoming @@ -1774,11 +1761,6 @@ object LocalCache : ILocalCache, ICacheProvider { note.loadEvent(event, author, repliesTo) refreshNewNoteObservers(note) - // First sender to claim a txid wins the index — duplicates are rare - // (the verifier proves the tx pays the recipient) and keeping a single - // attribution matches what the on-chain wallet UI shows. - event.txid()?.let { onchainZapsByTxid.putIfAbsent(it, event) } - // Verification needs a chain backend. Without one (e.g. before Account // wires its EsploraBackend) the event is still cached so subscriptions // and profile zap views see it, but it can't contribute to Note totals. 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 4d9b372dd..a3e747240 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 @@ -123,7 +123,7 @@ class OnchainTransactionsViewModel : ViewModel() { _hasMoreTransactions.value = true try { val rows = withContext(Dispatchers.IO) { be.getTxsForAddress(addr, null) } - val views = rows.map { OnchainTxView(it, LocalCache.getOnchainZapByTxid(it.txid)) } + val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } allTransactions.value = views lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid _hasMoreTransactions.value = lastSeenTxid != null @@ -148,7 +148,7 @@ class OnchainTransactionsViewModel : ViewModel() { if (rows.isEmpty()) { _hasMoreTransactions.value = false } else { - val views = rows.map { OnchainTxView(it, LocalCache.getOnchainZapByTxid(it.txid)) } + val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } allTransactions.value = allTransactions.value + views lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid ?: seen } @@ -163,4 +163,21 @@ class OnchainTransactionsViewModel : ViewModel() { fun clearError() { _error.value = null } + + /** + * Scan `LocalCache.notes` for the [OnchainZapEvent] that references this + * txid. NIP-BC verifies that the on-chain output actually pays the recipient + * before the event is consumed, so we won't see multiple competing events + * for the same txid in practice — last writer wins is fine. + */ + private fun findZapForTxid(txid: String): OnchainZapEvent? { + var found: OnchainZapEvent? = null + LocalCache.notes.forEach { _, note -> + val ev = note.event + if (ev is OnchainZapEvent && ev.txid() == txid) { + found = ev + } + } + return found + } } From 020d5195b5d21ffcad6af9027035c39b46e868a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:35:31 +0000 Subject: [PATCH 4/5] feat(wallet): observe onchain zaps reactively, no more per-row scan OnchainTransactionsViewModel now subscribes to LocalCache via observeEvents for kind-8333 zaps that either p-tag the user (incoming) or are signed by the user (outgoing), merging both into a txid -> OnchainZapEvent map. The FilterIndex inside LocalCache narrows the fanout so we only wake on relevant events. The list of chain rows and the zap map are now independent StateFlows combined into the displayed views, so a zap event arriving after the page has loaded immediately attributes the existing row to its Nostr counterparty without a refresh. Replaces the per-row LocalCache.notes scan from the previous commit. --- .../wallet/OnchainTransactionsViewModel.kt | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) 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 a3e747240..dd206db30 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 @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinAddressTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress @@ -71,17 +72,28 @@ class OnchainTransactionsViewModel : ViewModel() { /** Most recent confirmed txid we've already fetched; null until we've loaded a page. */ private var lastSeenTxid: String? = null - private val allTransactions = MutableStateFlow>(emptyList()) + /** Raw chain-side rows, in display order (mempool + confirmed pages appended). */ + private val chainTxs = MutableStateFlow>(emptyList()) + + /** + * Local txid → OnchainZapEvent index for this screen, auto-maintained by + * `LocalCache.observeEvents`. Two observation flows are merged: incoming + * zaps (signed by someone else, p-tag = me) and outgoing zaps (signed by + * me). Since `consume(OnchainZapEvent)` rejects self-zaps, the two never + * collide on the same txid. + */ + private val zapsByTxid = MutableStateFlow>(emptyMap()) private val _transactionFilter = MutableStateFlow(TransactionFilter.ALL) val transactionFilter = _transactionFilter.asStateFlow() val filteredTransactions = - combine(allTransactions, _transactionFilter) { txs, filter -> + combine(chainTxs, zapsByTxid, _transactionFilter) { txs, zaps, filter -> + val views = txs.map { OnchainTxView(it, zaps[it.txid]) } when (filter) { - TransactionFilter.ALL -> txs - TransactionFilter.ZAPS -> txs.filter { it.zap != null } - TransactionFilter.NON_ZAPS -> txs.filter { it.zap == null } + TransactionFilter.ALL -> views + TransactionFilter.ZAPS -> views.filter { it.zap != null } + TransactionFilter.NON_ZAPS -> views.filter { it.zap == null } } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -107,6 +119,25 @@ class OnchainTransactionsViewModel : ViewModel() { address = runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() backend = LocalCache.onchainBackend _displayAddress.value = address + + // 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. + 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)) + combine( + LocalCache.observeEvents(incoming), + LocalCache.observeEvents(outgoing), + ) { incomingZaps, outgoingZaps -> + val merged = HashMap(incomingZaps.size + outgoingZaps.size) + (incomingZaps.asSequence() + outgoingZaps.asSequence()).forEach { z -> + val txid = z.txid() ?: return@forEach + merged[txid] = z + } + merged + }.collect { zapsByTxid.value = it } + } } fun setTransactionFilter(filter: TransactionFilter) { @@ -123,8 +154,7 @@ class OnchainTransactionsViewModel : ViewModel() { _hasMoreTransactions.value = true try { val rows = withContext(Dispatchers.IO) { be.getTxsForAddress(addr, null) } - val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } - allTransactions.value = views + chainTxs.value = rows lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid _hasMoreTransactions.value = lastSeenTxid != null } catch (t: Throwable) { @@ -148,8 +178,7 @@ class OnchainTransactionsViewModel : ViewModel() { if (rows.isEmpty()) { _hasMoreTransactions.value = false } else { - val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } - allTransactions.value = allTransactions.value + views + chainTxs.value = chainTxs.value + rows lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid ?: seen } } catch (t: Throwable) { @@ -163,21 +192,4 @@ class OnchainTransactionsViewModel : ViewModel() { fun clearError() { _error.value = null } - - /** - * Scan `LocalCache.notes` for the [OnchainZapEvent] that references this - * txid. NIP-BC verifies that the on-chain output actually pays the recipient - * before the event is consumed, so we won't see multiple competing events - * for the same txid in practice — last writer wins is fine. - */ - private fun findZapForTxid(txid: String): OnchainZapEvent? { - var found: OnchainZapEvent? = null - LocalCache.notes.forEach { _, note -> - val ev = note.event - if (ev is OnchainZapEvent && ev.txid() == txid) { - found = ev - } - } - return found - } } From 7dce3a420f10150ec1e95c74a55dc2fe2f86ea44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:52:12 +0000 Subject: [PATCH 5/5] feat(wallet): time-windowed relay sub + coalesce zap bursts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to onchain history attribution: 1. Coalesce UI updates. The OnchainZapEvent observation in the ViewModel now passes through .sample(500), so a relay flooding a backlog of historical zaps in quick succession produces at most one map update per 500ms instead of one per event. The downstream combine + StateFlow conflation handles the rest. 2. Bound relay queries to the visible window. New OnchainZapsFilterAssembler (SingleSubEoseManager-based) wakes only while the screen is on top and asks the user's inbox/ outbox relays for kind-8333 events with since = the oldest visible blockTime — incoming (p-tag = user) and outgoing (authors = user) — so we don't drag the whole NIP-BC history when the user only scrolled through last week. As the user pages back, oldestBlockTime drops, the assembler re-subscribes with a wider window. OnchainTransactionsScreen now wires OnchainZapsFilterAssemblerSubscription with the StateFlow-derived window, and the coordinator owns the parent assembler so the lifecycle matches other screen subs. --- .../RelaySubscriptionsCoordinator.kt | 5 + .../wallet/OnchainTransactionsScreen.kt | 8 + .../wallet/OnchainTransactionsViewModel.kt | 28 +++- .../datasource/OnchainZapsFilterAssembler.kt | 137 ++++++++++++++++++ .../OnchainZapsFilterAssemblerSubscription.kt | 50 +++++++ 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/datasource/OnchainZapsFilterAssemblerSubscription.kt 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 f1ef8d84d..d4322fcda 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 f93cf14b0..0e15cf8e0 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 dd206db30..23567fc24 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 000000000..ce9a003f8 --- /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 000000000..1daf4acfe --- /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) +}