From acf3daaf75a944d7a970028f64549b89df747d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:54:04 +0000 Subject: [PATCH] feat(wallet): tappable onchain rows + txid index in LocalCache - LocalCache now keeps a ConcurrentHashMap populated in consume(OnchainZapEvent). First sender to claim a txid wins. The on-chain transactions ViewModel switches to LocalCache.getOnchainZapByTxid(txid), replacing the per-row scan of notes. - Each row in OnchainTransactionsScreen is now clickable: rows with a matched zap event navigate to the zap's note thread; the rest open the transaction on mempool.space in the system browser. --- .../amethyst/model/LocalCache.kt | 18 ++++++++++ .../wallet/OnchainTransactionsScreen.kt | 35 ++++++++++++++++++- .../wallet/OnchainTransactionsViewModel.kt | 23 ++---------- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d60787d017..7ba613c8b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -271,6 +271,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.util.SortedSet +import java.util.concurrent.ConcurrentHashMap interface ILocalCache { fun markAsSeen( @@ -295,6 +296,18 @@ object LocalCache : ILocalCache, ICacheProvider { val paymentTracker = NwcPaymentTracker() + /** + * Index from on-chain transaction id → the first [OnchainZapEvent] we + * accepted that claims it. Populated in [consume]`(OnchainZapEvent)` after + * the event passes verification. The wallet's on-chain history view uses + * it to attribute a Nostr sender/recipient to a chain row without scanning + * [notes] for every transaction. + */ + private val onchainZapsByTxid = ConcurrentHashMap() + + /** Returns the cached [OnchainZapEvent] for [txid], if any. */ + fun getOnchainZapByTxid(txid: String): OnchainZapEvent? = onchainZapsByTxid[txid] + /** * Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps * against the actual on-chain transaction. `null` disables verification (incoming @@ -1761,6 +1774,11 @@ object LocalCache : ILocalCache, ICacheProvider { note.loadEvent(event, author, repliesTo) refreshNewNoteObservers(note) + // First sender to claim a txid wins the index — duplicates are rare + // (the verifier proves the tx pays the recipient) and keeping a single + // attribution matches what the on-chain wallet UI shows. + event.txid()?.let { onchainZapsByTxid.putIfAbsent(it, event) } + // Verification needs a chain backend. Without one (e.g. before Account // wires its EsploraBackend) the event is still cached so subscriptions // and profile zap views see it, but it can't contribute to Note totals. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt index 28006b0381..f93cf14b0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -50,6 +51,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -59,6 +62,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -165,6 +169,7 @@ fun OnchainTransactionsScreen( EmptyMessage(padding, stringRes(R.string.wallet_no_transactions)) } else -> { + val uriHandler = LocalUriHandler.current LazyColumn( modifier = Modifier.padding(padding), state = listState, @@ -174,7 +179,12 @@ fun OnchainTransactionsScreen( TransactionFilterRow(currentFilter) { viewModel.setTransactionFilter(it) } } items(transactions, key = { it.tx.txid }) { txView -> - OnchainTransactionItem(txView, accountViewModel, nav) + OnchainTransactionItem( + view = txView, + accountViewModel = accountViewModel, + nav = nav, + onClick = { handleTxClick(txView, nav, uriHandler) }, + ) HorizontalDivider() } if (isLoadingMore) { @@ -268,6 +278,7 @@ private fun OnchainTransactionItem( view: OnchainTxView, accountViewModel: AccountViewModel, nav: INav, + onClick: () -> Unit, ) { val isIncoming = view.isIncoming val amountSats = view.tx.netValueSats.absoluteValue @@ -295,6 +306,7 @@ private fun OnchainTransactionItem( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -390,6 +402,27 @@ private fun OnchainTransactionItem( } } +/** + * Dispatch a transaction-row tap: jump to the matched on-chain zap event's + * thread when we have one, otherwise open the tx on a public block explorer. + * Mempool.space works over Tor and clearnet; deriving the user's configured + * explorer URL would also need to know whether Bitcoin traffic is being + * routed over Tor right now, which the UI layer doesn't carry — stick with + * mempool.space as a sensible default. + */ +private fun handleTxClick( + view: OnchainTxView, + nav: INav, + uriHandler: UriHandler, +) { + val zap = view.zap + if (zap != null) { + nav.nav(Route.Note(zap.id)) + } else { + runCatching { uriHandler.openUri("https://mempool.space/tx/${view.tx.txid}") } + } +} + @Composable private fun OnchainCounterpartyName( pubkeyHex: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt index 6de440565d..4d9b372dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainTransactionsViewModel.kt @@ -123,7 +123,7 @@ class OnchainTransactionsViewModel : ViewModel() { _hasMoreTransactions.value = true try { val rows = withContext(Dispatchers.IO) { be.getTxsForAddress(addr, null) } - val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } + val views = rows.map { OnchainTxView(it, LocalCache.getOnchainZapByTxid(it.txid)) } allTransactions.value = views lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid _hasMoreTransactions.value = lastSeenTxid != null @@ -148,7 +148,7 @@ class OnchainTransactionsViewModel : ViewModel() { if (rows.isEmpty()) { _hasMoreTransactions.value = false } else { - val views = rows.map { OnchainTxView(it, findZapForTxid(it.txid)) } + val views = rows.map { OnchainTxView(it, LocalCache.getOnchainZapByTxid(it.txid)) } allTransactions.value = allTransactions.value + views lastSeenTxid = rows.lastOrNull { it.confirmations > 0 }?.txid ?: seen } @@ -163,23 +163,4 @@ class OnchainTransactionsViewModel : ViewModel() { fun clearError() { _error.value = null } - - /** - * Scan `LocalCache.notes` for the [OnchainZapEvent] that references this - * txid. NIP-BC is anti-spoofing-checked on consume, so any event we find - * here has already been accepted as authentic and matches our chain row. - * There's no txid-indexed map yet — the scan stays acceptable while - * on-chain zap volume is small. - */ - private fun findZapForTxid(txid: String): OnchainZapEvent? { - var found: OnchainZapEvent? = null - LocalCache.notes.forEach { _, note -> - if (found != null) return@forEach - val ev = note.event - if (ev is OnchainZapEvent && ev.txid() == txid) { - found = ev - } - } - return found - } }