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/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 10834421d..15e9110a8 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 @@ -179,6 +179,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 @@ -267,6 +268,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 b9e4ae495..95766fb5a 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..0e15cf8e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -0,0 +1,456 @@ +/* + * 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.clickable +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.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 +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.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 +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 +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 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() + 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 -> { + val uriHandler = LocalUriHandler.current + LazyColumn( + modifier = Modifier.padding(padding), + state = listState, + ) { + item { AddressHeader(address) } + item { + TransactionFilterRow(currentFilter) { viewModel.setTransactionFilter(it) } + } + items(transactions, key = { it.tx.txid }) { txView -> + OnchainTransactionItem( + view = txView, + accountViewModel = accountViewModel, + nav = nav, + onClick = { handleTxClick(txView, nav, uriHandler) }, + ) + 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, + onClick: () -> Unit, +) { + 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() + .clickable(onClick = onClick) + .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 + }, + ) + } +} + +/** + * 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, + 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..23567fc24 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt @@ -0,0 +1,219 @@ +/* + * 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.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 +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 + +/** + * 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() + } +} + +private const val SAMPLE_MILLIS = 500L + +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 + + /** 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(chainTxs, zapsByTxid, _transactionFilter) { txs, zaps, filter -> + val views = txs.map { OnchainTxView(it, zaps[it.txid]) } + when (filter) { + TransactionFilter.ALL -> views + TransactionFilter.ZAPS -> views.filter { it.zap != null } + TransactionFilter.NON_ZAPS -> views.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() + + /** + * 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 + 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. .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), + ) { 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 + }.sample(SAMPLE_MILLIS).collect { zapsByTxid.value = it } + } + } + + 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) } + chainTxs.value = rows + 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 { + chainTxs.value = chainTxs.value + rows + 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 + } +} 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/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) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 48063d53f..39efc0ce4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1851,6 +1851,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