From 8ffe1aee4fca5e90fbfda4a9956858f6c301d9ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 22:19:47 +0000 Subject: [PATCH] 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