From 7a5193bcec589a9b8e81d4eb6bbbbae583c793cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:43:56 +0000 Subject: [PATCH] feat: page the Cashu transaction list backward instead of showing a relay's suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind:7375 fix made the balance whole; the transaction list was still whatever one uncapped REQ returned. CashuWalletEoseManager asks six kinds of one relay in a single REQ with no limit, and kind:7376 history is the most numerous of them, so the list every device shows is a recent-N suffix chosen by that relay's cap — and no later REQ asks for the rest, because the EOSE moves `since` forward. Proofs and history want opposite fixes. A balance summed over a partial proof set is wrong rather than incomplete, so those are walked to exhaustion in one shot. History is display-only and unbounded, and the user reads it newest-first, so pulling all of it at launch would be a large download for something they may never scroll. That is exactly the shape until+limit paging is for. Built on the existing machinery rather than a new one: BackwardRelayPager with cursors on the Account (Account.cashuHistory, beside notificationHistory), modelled on AccountNotificationsHistoryEoseManager for the loader and on the NIP-29 thread list for the two UI drivers — a bootstrap that fills the first screen and a look-ahead that pulls another page only while the user is scrolling toward the bottom. Nothing is fetched while the wallet is off screen, and a relay that finished a page parks at its cursor so another relay advancing doesn't re-REQ it. One deliberate divergence from the DM and notification pagers: they floor at `now - liveTail` because a separate live loader provably covers everything newer. The wallet has no such guarantee — its live REQ carries neither `since` nor `limit`, so how far back it reaches is whatever the relay decided, which is the bug being fixed. Flooring at a fixed tail would leave a band between the relay's cap and the tail boundary that neither loader ever asks for. This pager floors at `now` and overlaps the live subscription completely; duplicates are free (both LocalCache and CashuWalletState.historyEvents are keyed by event id) and gaplessness is worth more than the overlap. The footer splits on stalledCount rather than reporting `exhausted` as "all loaded": exhausted means nothing more is reachable right now, and a relay that answered an auth CLOSE or went silent is stalled, not done. Telling someone their transaction history is complete when part of it was never served is a lie about their own money. Page limit is 100, not the notification pager's 500 — every kind:7376 row costs a NIP-44 decrypt to render, which on an external signer is an out-of-process round-trip. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY --- .../vitorpamplona/amethyst/model/Account.kt | 6 + .../account/AccountFilterAssembler.kt | 7 + .../CashuWalletHistoryEoseManager.kt | 225 ++++++++++++++++++ .../nip60Cashu/FilterCashuHistoryToPubkey.kt | 65 +++++ .../loggedIn/wallet/CashuWalletScreen.kt | 98 ++++++++ amethyst/src/main/res/values/strings.xml | 3 + .../nip60Cashu/FilterCashuHistoryTest.kt | 74 ++++++ 7 files changed, 478 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 882472ef9b..e4d31643ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -704,6 +704,12 @@ class Account( // the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these. val notificationHistory = RelayLoadingCursors() + // Per-relay backward-paging cursors for the NIP-60 spending history (kind:7376): how far back each + // outbox relay has been paged by until+limit. Same lifetime rule as notificationHistory — held here + // so paging progress survives leaving and re-entering the wallet screen; the history loader + // ([CashuWalletHistoryEoseManager]) binds its orchestrator to these. + val cashuHistory = RelayLoadingCursors() + val cashuWalletState = com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState( pubKey = signer.pubKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index a5f530da31..c75563b85f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47Wa import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletHistoryEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -96,6 +97,11 @@ class AccountFilterAssembler( // History: older notifications, paged backward by until+limit per relay, driven by the feed's markers. val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys) + // History: older NIP-60 spending rows (kind:7376), paged backward by until+limit per outbox relay, + // driven by the wallet's transaction list. The live wallet subscription below reads six kinds in one + // uncapped REQ, so history — the most numerous of them — is exactly what a relay's cap truncates. + val cashuWalletHistory = CashuWalletHistoryEoseManager(client, ::preferredKeys) + val group = listOf( AccountMetadataEoseManager(client, ::preferredKeys), @@ -109,6 +115,7 @@ class AccountFilterAssembler( // NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector // inside CashuWalletState, so it starts and stops with every other account-level loader. CashuWalletEoseManager(client, ::preferredKeys), + cashuWalletHistory, MarmotGroupEventsEoseManager(client, ::preferredKeys), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt new file mode 100644 index 0000000000..b1ee4f5b9a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/CashuWalletHistoryEoseManager.kt @@ -0,0 +1,225 @@ +/* + * 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.service.relayClient.reqCommand.account.nip60Cashu + +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +/** + * Loads the account's NIP-60 spending **history** (kind:7376) by **`until`+`limit` paging, per relay, on + * demand**, so the wallet's transaction list can be scrolled back through a wallet's whole lifetime + * instead of showing whatever suffix one uncapped REQ happened to return. + * + * ### Why history needs its own loader + * + * [CashuWalletEoseManager] opens one live subscription per outbox relay covering six kinds at once, with + * no `limit`. A relay answers that with its own cap applied to the newest matching events, and history + * rows are the most numerous kind in the query — so on a wallet with a few hundred transactions the list + * is truncated to a recent-N view that differs per device, and no later REQ ever asks for the rest (the + * EOSE moves `since` forward). That is the same truncation that was silently costing balance, except + * here the fix is paging rather than a one-shot walk: history is display-only and unbounded, so pulling + * all of it at launch would be a large download for something the user may never scroll. + * + * ### How it pages + * + * There is no proactive walk. Each relay advances exactly one page when the transaction list asks + * ([advance] / [advanceAll]), then **parks** — a relay that finished a page keeps the same `until` in + * [updateFilter], so re-assembly triggered by *another* relay advancing does not re-REQ it. The list is + * the driver: it pulls a page on open and another whenever the user scrolls near the end, so nothing is + * fetched while the wallet is off screen. + * + * Paged over the account's **outbox** relays — the same set the wallet publishes its own events to, and + * so the same set [CashuWalletEoseManager] reads its own kinds back from. + * + * ### Floor: no live tail + * + * The DM/notification pagers floor at `now − liveTail` because a separate live loader is known to cover + * everything newer. The wallet has no such guarantee: its live REQ carries no `since` and no `limit`, so + * how far back it actually reaches is whatever the relay decided — which is the very thing being fixed + * here. Flooring at a fixed tail would therefore leave a gap between the relay's cap and the tail + * boundary that neither loader ever asks for. So this pager floors at **now** and overlaps the live + * subscription completely; duplicates cost nothing (both `LocalCache` and `CashuWalletState.historyEvents` + * are keyed by event id) and gaplessness is worth more than the overlap. + * + * The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds + * the single-active [BackwardRelayPager] to them on [newSub], builds the REQ filters, and forwards relay + * callbacks into the pager. A relay is *done* once it answers an empty page; one that will not answer + * (auth CLOSE, unreachable, silent) is flagged *stalled* but kept, and [PagingStatus.exhausted] flips + * once every relay is done or stalled — callers rendering a terminal state should split on + * [PagingStatus.stalledCount]. + */ +class CashuWalletHistoryEoseManager( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun user(key: AccountQueryState) = key.account.userProfile() + + // liveTailSeconds = 0 pins the floor at `now` — see the class doc on why the wallet, unlike DMs, + // cannot assume a live loader already covers a recent window. + private val pager = BackwardRelayPager("cashu.history", pageLimit = PAGE_LIMIT, liveTailSeconds = 0L) + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + /** The relays this account pages its own NIP-60 history back through: where it publishes. */ + private fun historyRelaySet(account: Account): Set = account.outboxRelays.flow.value + + override fun updateFilter( + key: AccountQueryState, + since: SincePerRelayMap?, + ): List { + val pubkey = user(key).pubkeyHex + val relays = historyRelaySet(key.account) + + // Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished + // a page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) + // doesn't re-REQ it — it stays parked until the list advances it again. + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + + return armed.flatMap { relay -> + val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList() + Log.d(TAG) { "[cashu.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" } + filterCashuHistoryToPubkey(relay, pubkey, until, pager.pageLimit) + } + } + + /** Steps a single [relay] to its next, older page. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. What the transaction list drives. */ + fun advanceAll() { + if (pager.advanceAll()) { + Log.d(TAG) { "[cashu.history] advanceAll" } + invalidateFilters() + } + } + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AccountQueryState): Subscription { + // Repoint the single-active orchestrator at this account's cashu-history cursors and the relay + // set it fans out to, refreshing the display flows from the restored progress. + pager.bind(key.account.cashuHistory, key.account.scope) { historyRelaySet(key.account) } + + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + // A relay joining/leaving the outbox set re-issues the REQ so a newly-added relay can be + // armed and a removed one drops out. Sampled — a relay-list edit lands as a burst. + key.account.scope.launch(Dispatchers.IO) { + key.account.outboxRelays.flow + .sample(1000) + .collectLatest { invalidateFilters() } + }, + ) + + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: AccountQueryState): SubscriptionListener { + // A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to + // another account; gate the pager (single-active) on whether it's still bound to THIS account's + // cursors so a late callback can't move another account's cursors. newEose runs regardless. + val myCursors = key.account.cashuHistory + return object : SubscriptionListener { + override suspend fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors) && pager.onEose(relay)) { + Log.d(TAG) { "[cashu.history] ${relay.url} reached the bottom (done)" } + } + // No auto-advance: the relay parks here until the transaction list asks for another page. + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } + + companion object { + private const val TAG = "CashuPagination" + + /** + * Rows pulled per relay per advance. Smaller than the notification pager's 500: every kind:7376 + * row costs a NIP-44 decrypt to render, which on an external signer is an out-of-process + * round-trip, so a page is sized to fill a screen or two rather than to fill memory. + */ + const val PAGE_LIMIT = 100 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt new file mode 100644 index 0000000000..94f0b68c43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryToPubkey.kt @@ -0,0 +1,65 @@ +/* + * 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.service.relayClient.reqCommand.account.nip60Cashu + +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent + +/** + * One backward-paging page of the account's NIP-60 spending history (kind:7376) on one of its outbox + * relays: my own history rows, strictly older than [until], newest-first, capped at [limit]. + * + * Deliberately kind:7376 only. The proofs (kind:7375) are not paged on demand — a balance computed from + * a partial proof set is simply wrong, so those are walked to exhaustion in one shot by + * `CashuWalletState.resyncProofsFromRelays`. History is the opposite: it is display-only, unbounded in + * length, and the user reads it newest-first, so it is exactly the shape `until`+`limit` paging is for. + * + * `until`+`limit` rather than a `since`/`until` window for the reason in `RelayLoadingCursors`: an empty + * time slice cannot distinguish "nothing older here" from "a quiet month", whereas an empty + * `until`+`limit` page is gap-proof proof of the bottom. + */ +fun filterCashuHistoryToPubkey( + relay: NormalizedRelayUrl, + pubkey: HexKey?, + until: Long, + limit: Int, +): List { + if (pubkey.isNullOrEmpty()) return emptyList() + + return listOf( + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.WALLET, + accountPubKeys = listOfNotNull(pubkey), + kinds = listOf(CashuSpendingHistoryEvent.KIND), + authors = listOf(pubkey), + limit = limit, + until = until, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt index 1d7941e222..fb85eaec3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -32,7 +32,9 @@ 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.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog @@ -64,6 +66,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard @@ -74,6 +77,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Cashu @@ -82,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletHistoryEoseManager import com.vitorpamplona.amethyst.ui.components.util.getText import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -96,6 +101,9 @@ import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import java.text.DateFormat import java.text.NumberFormat @@ -382,7 +390,19 @@ private fun CashuWalletContent( onMoveCoins: (String) -> Unit, onResumePendingQuote: () -> Unit, ) { + val listState = rememberLazyListState() + + // The kind:7376 rows below are only ever as complete as the relays were asked to be. The live wallet + // subscription asks for six kinds in one uncapped REQ, and history is the most numerous of them, so + // what lands there is a recent-N suffix chosen by each relay's cap. This pager walks older pages on + // demand — see CashuWalletHistoryEoseManager. + val history7376 = remember(accountViewModel) { accountViewModel.dataSources().account.cashuWalletHistory } + val loadingOlder by history7376.loadingMore.collectAsStateWithLifecycle() + val pagingStatus by history7376.status.collectAsStateWithLifecycle() + CashuHistoryPaging(historyCount = { history.size }, listState = listState, history = history7376) + LazyColumn( + state = listState, modifier = modifier .fillMaxSize() @@ -447,12 +467,90 @@ private fun CashuWalletContent( items(history, key = { it.id }) { entry -> HistoryRow(entry, accountViewModel, nav) } + item { + CashuHistoryFooter( + loadingOlder = loadingOlder, + exhausted = pagingStatus.exhausted, + stalledCount = pagingStatus.stalledCount, + ) + } } item { Spacer(modifier = Modifier.height(24.dp)) } } } +/** How many history rows to pull in before the user has scrolled at all. */ +private const val CASHU_HISTORY_TARGET = 30 + +/** How close to the end of the list a page request fires. */ +private const val CASHU_HISTORY_PREFETCH_AHEAD = 5 + +/** + * Drives the spending-history backward pager: pull a page on open so the list isn't whatever suffix the + * relay caps returned, then page older rows as the list nears its end. Same two-driver shape the NIP-29 + * thread list and the notifications feed use — a bootstrap that fills the first screen, and a look-ahead + * that keeps going only while the user is actually scrolling toward the bottom. + */ +@Composable +private fun CashuHistoryPaging( + historyCount: () -> Int, + listState: LazyListState, + history: CashuWalletHistoryEoseManager, +) { + LaunchedEffect(history) { + combine(snapshotFlow { historyCount() }, history.loadingMore, history.status) { count, loading, s -> + count < CASHU_HISTORY_TARGET && !loading && !s.exhausted + }.distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } + LaunchedEffect(history, listState) { + snapshotFlow { + val last = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 + val total = historyCount() + total > 0 && last >= total - CASHU_HISTORY_PREFETCH_AHEAD + }.distinctUntilChanged() + .filter { it } + .collect { + if (!history.status.value.exhausted && !history.loadingMore.value) history.advanceAll() + } + } +} + +/** + * A quiet footer under the transaction list: what the pager is doing, or nothing when idle. + * + * Splits on [stalledCount] because `exhausted` means "nothing more reachable right now", not "caught + * up" — a relay that answered an auth CLOSE, is unreachable, or went silent is stalled rather than done, + * and claiming the history is complete while some of it was never served would be a lie about the user's + * own money. + */ +@Composable +private fun CashuHistoryFooter( + loadingOlder: Boolean, + exhausted: Boolean, + stalledCount: Int, +) { + val text = + when { + loadingOlder -> stringRes(R.string.cashu_history_loading_older) + exhausted && stalledCount > 0 -> stringRes(R.string.cashu_history_some_relays_unreachable) + exhausted -> stringRes(R.string.cashu_history_all_loaded) + else -> return + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + ) +} + /** * Banner that surfaces unfinished mint quotes — tappable to resume the * receive flow with the stored invoice. Driven by diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97e1ef306e..32ebe48c0c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3255,6 +3255,9 @@ Remove mint Add mint History + Loading older transactions… + No older transactions + No older transactions from the relays that answered — some could not be reached Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint. Saving… Nutzap key (advanced) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt new file mode 100644 index 0000000000..e6c7c82fc2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip60Cashu/FilterCashuHistoryTest.kt @@ -0,0 +1,74 @@ +/* + * 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.service.relayClient.reqCommand.account.nip60Cashu + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the backward-paging Cashu history filter: it must ask for the N newest kind:7376 rows I authored + * strictly OLDER than a cursor (`until`+`limit`, no `since`), so the single per-relay cursor the + * [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager] tracks + * can't skip a band and an empty page truly means "nothing older" (see RelayLoadingCursors). + */ +class FilterCashuHistoryTest { + private val relay = RelayUrlNormalizer.normalize("wss://outbox.example.com") + private val pubkey = "aa".repeat(32) + private val until = 1_700_000_000L + + @Test + fun `history filter asks one until+limit page of my own rows, no since`() { + val filters = filterCashuHistoryToPubkey(relay, pubkey, until, 100) + + assertEquals(1, filters.size) + val f = filters.first().filter + assertEquals(relay, filters.first().relay) + assertEquals(until, f.until) + assertEquals(100, f.limit) + assertNull("history pages by until, never since", f.since) + // Own events are read back by author, not by a #p tag — unlike notifications, these are mine. + assertEquals(listOf(pubkey), f.authors) + } + + @Test + fun `history filter is scoped to kind 7376 alone`() { + val f = filterCashuHistoryToPubkey(relay, pubkey, until, 100).first().filter + + assertEquals(listOf(CashuSpendingHistoryEvent.KIND), f.kinds) + // Proofs must never be paged on demand: a balance summed over a partial kind:7375 set is wrong, + // not merely incomplete, so those are walked to exhaustion by CashuWalletState instead. + assertTrue( + "kind:7375 must not ride along on a demand-paged query", + CashuTokenEvent.KIND !in f.kinds.orEmpty(), + ) + } + + @Test + fun `empty pubkey yields no filter`() { + assertTrue(filterCashuHistoryToPubkey(relay, null, until, 100).isEmpty()) + assertTrue(filterCashuHistoryToPubkey(relay, "", until, 100).isEmpty()) + } +}