mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+8
-1
@@ -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())
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+38
-30
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user