feat: page the Cashu transaction list backward instead of showing a relay's suffix

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
This commit is contained in:
Claude
2026-08-17 00:43:56 +00:00
parent 954560666a
commit 7a5193bcec
7 changed files with 478 additions and 0 deletions
@@ -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,
@@ -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),
)
@@ -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<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
/** The relays this account pages its own NIP-60 history back through: where it publishes. */
private fun historyRelaySet(account: Account): Set<NormalizedRelayUrl> = account.outboxRelays.flow.value
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
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
}
}
@@ -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<RelayBasedFilter> {
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,
),
),
)
}
@@ -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
+3
View File
@@ -3255,6 +3255,9 @@
<string name="cashu_remove_mint">Remove mint</string>
<string name="cashu_add_mint">Add mint</string>
<string name="cashu_history">History</string>
<string name="cashu_history_loading_older">Loading older transactions…</string>
<string name="cashu_history_all_loaded">No older transactions</string>
<string name="cashu_history_some_relays_unreachable">No older transactions from the relays that answered — some could not be reached</string>
<string name="cashu_wallet_autosaves">Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint.</string>
<string name="cashu_wallet_saving">Saving…</string>
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
@@ -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())
}
}