Merge pull request #2974 from vitorpamplona/claude/clickable-wallet-card-hHTbM

Add on-chain transaction history screen with pagination
This commit is contained in:
Vitor Pamplona
2026-05-19 10:35:31 -04:00
committed by GitHub
18 changed files with 1094 additions and 1 deletions
@@ -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.shorts.datasource.ShortsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler 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.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.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
@@ -127,6 +128,9 @@ class RelaySubscriptionsCoordinator(
// active when sending zaps via NWC // active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client) val nwc = NWCPaymentFilterAssembler(client)
// active when the wallet's on-chain transactions screen is on top.
val onchainZaps = OnchainZapsFilterAssembler(client)
val all = val all =
listOf( listOf(
account, account,
@@ -167,6 +171,7 @@ class RelaySubscriptionsCoordinator(
relayInfoNip66, relayInfoNip66,
chess, chess,
nwc, nwc,
onchainZaps,
) )
fun destroy() = all.forEach { it.destroy() } fun destroy() = all.forEach { it.destroy() }
@@ -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.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
@@ -267,6 +268,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,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,
)
}
}
}
@@ -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<List<BitcoinAddressTx>>(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<Map<String, OnchainZapEvent>>(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<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()
/**
* 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<Long?> =
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<OnchainZapEvent>(incoming),
LocalCache.observeEvents<OnchainZapEvent>(outgoing),
) { incomingZaps, outgoingZaps ->
val merged = HashMap<String, OnchainZapEvent>(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
}
}
@@ -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) {
@@ -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<NormalizedRelayUrl>,
windowSinceSeconds: Long?,
eoseSince: SincePerRelayMap?,
): List<RelayBasedFilter> =
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<OnchainZapsQueryState>,
) : SingleSubEoseManager<OnchainZapsQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<OnchainZapsQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val key = keys.firstOrNull() ?: return emptyList()
val user = key.user
val relays: Collection<NormalizedRelayUrl> =
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<OnchainZapsQueryState>() {
private val sub = OnchainZapsFilterSubAssembler(client, ::allKeys)
override fun invalidateFilters() = sub.invalidateFilters()
override fun invalidateKeys() = invalidateFilters()
override fun destroy() = sub.destroy()
}
@@ -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)
}
+4
View File
@@ -1851,6 +1851,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