fix(cashu): audit fixes for wallet wizard

Three issues found in an adversarial audit of the wizard:

1. (correctness) recoverFromSeed bumped the NUT-13 counter only when
   unspent proofs remained, but nextCounterAfterScan reflects every slot
   the mint signed regardless of spent state. Adopting a fully-spent
   wallet on a fresh device would leave the counter at 0, so the next
   mint would reuse an already-signed slot and produce an unspendable
   proof. Now bumps past every signed slot (matches the old
   restoreFromMint behavior); the delta>0 check still no-ops when nothing
   was signed.

2. (UX trap) the wallet screen's auto-launch-into-wizard guard used
   `remember`, which resets when the screen leaves composition on
   forward navigation — so backing out of the wizard re-fired the effect
   and trapped the user in an inescapable loop. Switched to
   rememberSaveable so the guard survives the round trip.

3. (robustness) wrapped the wizard's analyze() in try/catch so an
   unexpected throw surfaces an error + retry instead of spinning on
   "Analyzing…" forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
This commit is contained in:
Claude
2026-06-26 18:31:37 +00:00
parent 172df295f8
commit 0186aad1f4
3 changed files with 52 additions and 34 deletions
@@ -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())
@@ -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
@@ -185,38 +185,46 @@ class CashuWalletWizardViewModel : ViewModel() {
_wizardState.value = WizardState.Analyzing
discoveredNutzapInfo = done.nutzapInfoEvents.firstOrNull()
vm.launchSigner {
val found = mutableListOf<FoundWallet>()
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<FoundWallet>()
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))
}
}
}