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.
This commit is contained in:
Claude
2026-05-16 22:19:47 +00:00
parent 280f21159f
commit 8ffe1aee4f
15 changed files with 827 additions and 1 deletions
@@ -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.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen 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.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.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
@@ -266,6 +267,7 @@ fun BuildNavigation(
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) } composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) } composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) } composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) } composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) } composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
@@ -111,6 +111,8 @@ sealed class Route {
@Serializable object WalletTransactions : Route() @Serializable object WalletTransactions : Route()
@Serializable object OnchainTransactions : Route()
@Serializable @Serializable
data class WalletDetail( data class WalletDetail(
val walletId: String, val walletId: String,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.util.setText 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress
@@ -83,6 +86,7 @@ import java.text.NumberFormat
@Composable @Composable
fun OnchainSection( fun OnchainSection(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pubKey = accountViewModel.account.signer.pubKey val pubKey = accountViewModel.account.signer.pubKey
@@ -118,7 +122,10 @@ fun OnchainSection(
val orange = MaterialTheme.colorScheme.bitcoinColor val orange = MaterialTheme.colorScheme.bitcoinColor
Card( Card(
modifier = modifier.fillMaxWidth(), modifier =
modifier
.fillMaxWidth()
.clickable { nav.nav(Route.OnchainTransactions) },
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
border = BorderStroke(2.dp, orange), border = BorderStroke(2.dp, orange),
colors = colors =
@@ -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,
)
}
}
}
@@ -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<List<OnchainTxView>>(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<String?>(null)
val error = _error.asStateFlow()
/** The Taproot address being displayed, exposed for the UI header. */
private val _displayAddress = MutableStateFlow<String?>(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
}
}
@@ -127,6 +127,7 @@ fun WalletScreen(
Column(modifier = Modifier.padding(padding)) { Column(modifier = Modifier.padding(padding)) {
OnchainSection( OnchainSection(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
if (!hasWallet) { if (!hasWallet) {
+4
View File
@@ -1837,6 +1837,10 @@
<string name="wallet_invalid_uri">Invalid NWC connection URI</string> <string name="wallet_invalid_uri">Invalid NWC connection URI</string>
<string name="wallet_move_up">Move Up</string> <string name="wallet_move_up">Move Up</string>
<string name="wallet_move_down">Move Down</string> <string name="wallet_move_down">Move Down</string>
<string name="wallet_onchain_transactions">Onchain Transactions</string>
<string name="wallet_onchain_no_address">No on-chain address available for this account.</string>
<string name="wallet_onchain_no_backend">No chain backend is configured.</string>
<string name="wallet_onchain_pending">Pending</string>
<string name="route_security_filters">Security Filters</string> <string name="route_security_filters">Security Filters</string>
<string name="route_import_follows">Import Follows</string> <string name="route_import_follows">Import Follows</string>
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder 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.BitcoinTx
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
@@ -69,6 +70,11 @@ class OnchainZapSenderTest {
override suspend fun getUtxosForAddress(address: String): List<Utxo> = utxos override suspend fun getUtxosForAddress(address: String): List<Utxo> = utxos
override suspend fun getTxsForAddress(
address: String,
afterTxid: String?,
): List<BitcoinAddressTx> = emptyList()
override suspend fun broadcast(rawTxHex: String): String { override suspend fun broadcast(rawTxHex: String): String {
if (broadcastFails) throw RuntimeException("relay rejected tx") if (broadcastFails) throw RuntimeException("relay rejected tx")
broadcastedHex = rawTxHex broadcastedHex = rawTxHex
@@ -80,3 +80,36 @@ data class FeeEstimates(
val normalSatPerVbyte: Double, val normalSatPerVbyte: Double,
val slowSatPerVbyte: 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<String> = emptyList(),
)
@@ -85,6 +85,11 @@ class CachingOnchainBackend(
override suspend fun getUtxosForAddress(address: String): List<Utxo> = delegate.getUtxosForAddress(address) override suspend fun getUtxosForAddress(address: String): List<Utxo> = delegate.getUtxosForAddress(address)
override suspend fun getTxsForAddress(
address: String,
afterTxid: String?,
): List<BitcoinAddressTx> = delegate.getTxsForAddress(address, afterTxid)
override suspend fun broadcast(rawTxHex: String): String = delegate.broadcast(rawTxHex) override suspend fun broadcast(rawTxHex: String): String = delegate.broadcast(rawTxHex)
override suspend fun tipHeight(): Long { override suspend fun tipHeight(): Long {
@@ -33,6 +33,20 @@ interface OnchainBackend {
/** Fetch the spendable UTXOs paying `address` (bech32m taproot for NIP-BC). */ /** Fetch the spendable UTXOs paying `address` (bech32m taproot for NIP-BC). */
suspend fun getUtxosForAddress(address: String): List<Utxo> suspend fun getUtxosForAddress(address: String): List<Utxo>
/**
* 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<BitcoinAddressTx>
/** Broadcast a fully signed transaction (lowercase hex). Returns the txid. */ /** Broadcast a fully signed transaction (lowercase hex). Returns the txid. */
suspend fun broadcast(rawTxHex: String): String suspend fun broadcast(rawTxHex: String): String
@@ -41,6 +41,11 @@ class CachingOnchainBackendTest {
override suspend fun getUtxosForAddress(address: String): List<Utxo> = emptyList() override suspend fun getUtxosForAddress(address: String): List<Utxo> = emptyList()
override suspend fun getTxsForAddress(
address: String,
afterTxid: String?,
): List<BitcoinAddressTx> = emptyList()
override suspend fun broadcast(rawTxHex: String): String = "broadcast" override suspend fun broadcast(rawTxHex: String): String = "broadcast"
override suspend fun tipHeight(): Long { override suspend fun tipHeight(): Long {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nipBCOnchainZaps.verify package com.vitorpamplona.quartz.nipBCOnchainZaps.verify
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.BitcoinTx
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTxOutput import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTxOutput
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates
@@ -83,6 +84,11 @@ class OnchainZapVerifierTest {
override suspend fun getUtxosForAddress(address: String): List<Utxo> = emptyList() override suspend fun getUtxosForAddress(address: String): List<Utxo> = emptyList()
override suspend fun getTxsForAddress(
address: String,
afterTxid: String?,
): List<BitcoinAddressTx> = emptyList()
override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException() override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException()
override suspend fun tipHeight(): Long = tip override suspend fun tipHeight(): Long = tip
@@ -94,6 +94,35 @@ class EsploraBackend(
} }
} }
override suspend fun getTxsForAddress(
address: String,
afterTxid: String?,
): List<BitcoinAddressTx> {
// 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 { override suspend fun broadcast(rawTxHex: String): String {
val url = "${baseUrl()}/tx" val url = "${baseUrl()}/tx"
val request = 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<BitcoinAddressTx> {
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<String>()
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<String>()
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<Utxo> { internal fun parseUtxoList(json: String): List<Utxo> {
val node = JacksonMapper.mapper.readTree(json) val node = JacksonMapper.mapper.readTree(json)
return node.map { utxo -> return node.map { utxo ->
@@ -108,6 +108,62 @@ class EsploraBackendTest {
assertEquals(0, utxos[1].confirmations, "unconfirmed UTXO must report 0 confirmations") 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 @Test
fun parsesMempoolSpaceRecommendedFees() { fun parsesMempoolSpaceRecommendedFees() {
// mempool.space /v1/fees/recommended // mempool.space /v1/fees/recommended