diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 12794adabb..591c7cb0ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -1125,7 +1125,14 @@ class CashuWalletState( runCatching { val recoverable = ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets) val published = ops.publishRecoveredProofs(recoverable) - if (bumpCounter && !recoverable.isEmpty) { + if (bumpCounter) { + // Advance past EVERY slot the mint signed, even when all + // recovered proofs were already spent (recoverable.proofs + // empty after the NUT-07 filter). nextCounterAfterScan is + // set from the pre-checkstate scan, so the delta still + // covers those slots — without this a fully-spent adopt on + // a fresh device would leave the counter at 0 and the next + // mint would reuse an already-signed slot (unspendable). val current = settings.peekCashuCounter(recoverable.keysetId) val delta = (recoverable.nextCounterAfterScan - current).coerceAtLeast(0L) if (delta > 0) settings.reserveCashuCounters(recoverable.keysetId, delta.toInt()) 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 0b1026ea40..1d7941e222 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 @@ -62,6 +62,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -139,9 +140,11 @@ fun CashuWalletScreen( // Once discovery resolves to "no wallet here", drive the user into the // find-or-create wizard (which crawls every relay for a portable wallet - // before offering to create one). Guarded so returning from the wizard - // via back doesn't bounce the user straight back in. - var autoLaunchedWizard by remember { mutableStateOf(false) } + // before offering to create one). rememberSaveable (not remember) so the + // guard survives navigating into the wizard: this screen leaves composition + // on forward-nav, and a plain remember would reset to false on back, then + // re-fire the effect and trap the user in an inescapable wizard loop. + var autoLaunchedWizard by rememberSaveable { mutableStateOf(false) } LaunchedEffect(walletEvent == null && !discovering) { if (walletEvent == null && !discovering && !autoLaunchedWizard) { autoLaunchedWizard = true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt index 8ff056dd49..2b3724c09b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/wizard/CashuWalletWizardViewModel.kt @@ -185,38 +185,46 @@ class CashuWalletWizardViewModel : ViewModel() { _wizardState.value = WizardState.Analyzing discoveredNutzapInfo = done.nutzapInfoEvents.firstOrNull() vm.launchSigner { - val found = mutableListOf() - for (evt in done.walletEvents) { - val config = state.decryptDiscoveredWallet(evt) - if (config == null) { - found += FoundWallet(evt, evt.createdAt, emptyList(), false, null, emptyMap(), 0L) - continue - } - val recoverable = - if (config.privkeyHex != null && config.mints.isNotEmpty()) { - state.probeRecoverableFromSeed(config.privkeyHex, config.mints) - } else { - emptyMap() + // Per-mint probe failures are already swallowed inside + // probeRecoverableFromSeed; this outer guard catches anything else + // (a malformed key, an unexpected throw) so the wizard surfaces an + // error + retry instead of spinning on "Analyzing…" forever. + try { + val found = mutableListOf() + for (evt in done.walletEvents) { + val config = state.decryptDiscoveredWallet(evt) + if (config == null) { + found += FoundWallet(evt, evt.createdAt, emptyList(), false, null, emptyMap(), 0L) + continue } - found += - FoundWallet( - event = evt, - createdAt = evt.createdAt, - mints = config.mints, - valid = true, - privkeyHex = config.privkeyHex, - recoverableByMint = recoverable, - totalRecoverableSats = recoverable.values.sum(), - ) - } - - val valid = found.filter { it.valid }.sortedByDescending { it.createdAt } - _wizardState.value = - when { - valid.isEmpty() -> WizardState.NoWallet - valid.size == 1 -> WizardState.Single(valid.first()) - else -> WizardState.Multiple(main = valid.first(), others = valid.drop(1)) + val recoverable = + if (config.privkeyHex != null && config.mints.isNotEmpty()) { + state.probeRecoverableFromSeed(config.privkeyHex, config.mints) + } else { + emptyMap() + } + found += + FoundWallet( + event = evt, + createdAt = evt.createdAt, + mints = config.mints, + valid = true, + privkeyHex = config.privkeyHex, + recoverableByMint = recoverable, + totalRecoverableSats = recoverable.values.sum(), + ) } + + val valid = found.filter { it.valid }.sortedByDescending { it.createdAt } + _wizardState.value = + when { + valid.isEmpty() -> WizardState.NoWallet + valid.size == 1 -> WizardState.Single(valid.first()) + else -> WizardState.Multiple(main = valid.first(), others = valid.drop(1)) + } + } catch (e: Exception) { + _wizardState.value = WizardState.Error(describeMintError(e)) + } } }