mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(cashu): surface all token-holding mints and sync them on wallet open
Two related gaps around nutzaps redeemed from mints not in the user's configured kind:17375 list (e.g. a NIP-61 nutzap auto-redeemed from a mint outside the recipient's kind:10019): - The wallet screen's per-mint list iterated only the configured mints, so a token-only mint contributed to the total balance but had no row — the displayed per-mint balances under-counted the wallet. Add `displayMints` (union of configured + token-derived mints) so the rows sum to the full balance. - Stale-proof reconciliation (`scrubLocallyStaleProofs`) only ran for the single mint a spend targeted, so proofs held at a non-configured mint were never checked until spent. Add `syncAllMints()` (an all-mint, non-destructive sweep) and wire it to the wallet screen opening via `CashuWalletViewModel.refresh()`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
This commit is contained in:
+37
@@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -219,6 +220,23 @@ class CashuWalletState(
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
/**
|
||||
* Mints to surface in the wallet screen's per-mint list: the union of
|
||||
* our configured mints (kind:17375 — listed even at zero balance so the
|
||||
* user can top them up) and every mint we actually hold tokens at
|
||||
* (token-derived, via [mintBalances]). The token-derived half is what
|
||||
* keeps the per-mint rows summing to [balanceSats]: a balance
|
||||
* auto-redeemed from a mint we never configured (e.g. a nutzap on a mint
|
||||
* not in our kind:10019) contributes to the total, so without a row for
|
||||
* it the displayed mint balances would silently under-count the wallet.
|
||||
* Configured mints come first; extra token-only mints follow.
|
||||
*/
|
||||
val displayMints: StateFlow<List<String>> =
|
||||
combine(_mints, mintBalances) { configured, balances ->
|
||||
(configured + balances.keys).distinct()
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
|
||||
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
|
||||
|
||||
@@ -1143,6 +1161,25 @@ class CashuWalletState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively reconcile *every* mint we currently hold tokens at against
|
||||
* its NUT-07 `/checkstate` — not just the one mint a spend happens to
|
||||
* target. [scrubLocallyStaleProofs] with a null filter already iterates
|
||||
* the token-derived mint set ([mintBalances]), so proofs auto-redeemed
|
||||
* from a mint we never configured (e.g. a nutzap on a mint not in our
|
||||
* kind:10019) get their spent state checked here too, instead of sitting
|
||||
* unverified until the user happens to spend from that mint.
|
||||
*
|
||||
* Non-destructive (it only prunes proofs the mint reports SPENT) and
|
||||
* idempotent — safe to call on every wallet-screen open. Deliberately
|
||||
* does NOT run [migrateStaleKeysets]; that swap-then-publish sequence
|
||||
* isn't atomic and stays user-driven.
|
||||
*/
|
||||
suspend fun syncAllMints() {
|
||||
if (!started) return
|
||||
scrubLocallyStaleProofs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate proofs held on inactive keysets onto each mint's current
|
||||
* active keyset. Cheap when nothing needs migrating (one /v1/keys
|
||||
|
||||
+14
-1
@@ -113,12 +113,25 @@ fun CashuWalletScreen(
|
||||
|
||||
val walletEvent by viewModel.walletEvent.collectAsState()
|
||||
val discovering by viewModel.discovering.collectAsState()
|
||||
// `mints` is the configured (kind:17375) list — used by the send/receive
|
||||
// dialogs. `displayMints` adds any mint we merely hold tokens at so the
|
||||
// per-mint rows below sum to the full balance.
|
||||
val mints by viewModel.mints.collectAsState()
|
||||
val displayMints by viewModel.displayMints.collectAsState()
|
||||
val balanceSats by viewModel.balanceSats.collectAsState()
|
||||
val mintBalances by viewModel.mintBalances.collectAsState()
|
||||
val history by viewModel.history.collectAsState()
|
||||
val pendingQuotes by viewModel.pendingQuotes.collectAsState()
|
||||
|
||||
// Reconcile every mint we hold tokens at whenever the wallet opens —
|
||||
// sweeps stale proofs across all mints, not just the one a spend
|
||||
// targets, so a balance auto-redeemed from a mint not in our configured
|
||||
// list (e.g. a nutzap on a mint outside our kind:10019) still gets
|
||||
// checked. No-ops when the wallet is empty or nothing is stale.
|
||||
LaunchedEffect(walletEvent != null) {
|
||||
if (walletEvent != null) viewModel.refresh()
|
||||
}
|
||||
|
||||
var receiveOpen by remember { mutableStateOf(false) }
|
||||
var sendLnOpen by remember { mutableStateOf(false) }
|
||||
var sendTokenOpen by remember { mutableStateOf(false) }
|
||||
@@ -158,7 +171,7 @@ fun CashuWalletScreen(
|
||||
CashuWalletContent(
|
||||
modifier = Modifier.padding(padding),
|
||||
balanceSats = balanceSats,
|
||||
mints = mints,
|
||||
mints = displayMints,
|
||||
mintBalances = mintBalances,
|
||||
history = history,
|
||||
pendingQuoteCount = pendingQuotes.size,
|
||||
|
||||
+20
@@ -154,6 +154,7 @@ class CashuWalletViewModel : ViewModel() {
|
||||
|
||||
val walletEvent get() = state.walletEvent
|
||||
val mints get() = state.mints
|
||||
val displayMints get() = state.displayMints
|
||||
val balanceSats get() = state.balanceSats
|
||||
val mintBalances get() = state.mintBalances
|
||||
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
|
||||
@@ -187,6 +188,25 @@ class CashuWalletViewModel : ViewModel() {
|
||||
// that lifecycle and is alive for the whole login session.
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every mint we hold tokens at against its NUT-07 `/checkstate`
|
||||
* — not just the mint a spend targets. Wired to the wallet screen opening
|
||||
* so a balance auto-redeemed from a mint we never configured (e.g. a
|
||||
* nutzap on a mint not in our kind:10019) still gets its stale proofs
|
||||
* swept. Safe to call repeatedly; no-ops when nothing is stale or the
|
||||
* wallet hasn't started yet.
|
||||
*/
|
||||
fun refresh() {
|
||||
val vm = accountViewModel ?: return
|
||||
vm.launchSigner {
|
||||
try {
|
||||
state.syncAllMints()
|
||||
} catch (e: Exception) {
|
||||
Log.w("CashuWallet", "wallet refresh sync failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a mint URL is reachable + speaks Cashu v1. */
|
||||
fun pingMint(url: String) {
|
||||
val vm = accountViewModel ?: return
|
||||
|
||||
Reference in New Issue
Block a user