feat(wallet): tappable onchain rows + txid index in LocalCache

- LocalCache now keeps a ConcurrentHashMap<txid, OnchainZapEvent>
  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.
This commit is contained in:
Claude
2026-05-18 22:54:04 +00:00
parent 8ffe1aee4f
commit acf3daaf75
3 changed files with 54 additions and 22 deletions
@@ -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<String, OnchainZapEvent>()
/** 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.
@@ -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,
@@ -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
}
}