mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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,
|
||||
|
||||
+7
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
+225
@@ -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
|
||||
}
|
||||
}
|
||||
+65
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+98
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+74
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user