fix(cashu): address pre-merge audit findings

- Invalidate the cached NUT-13 seed in applyEvents whenever the live kind:17375
  changes, so after a P2PK key rotation (recreateNutzapKey, or a rotation from
  another client) deterministic secrets re-derive from the new key instead of a
  stale cached seed. Removes the now-redundant reset in recreateNutzapKey.
- AccountSettings.updateNutzapInfo no longer backs up a mints-less kind:10019
  (the "stop receiving nutzaps" tombstone), clearing the backup instead — so the
  empty event round-tripping back through LocalCache can't undo clearNutzapInfo()
  and resurrect a withdrawn nutzap advertisement on next launch.
- Key keyMode's remember on isEditMode in AddCashuWalletScreen so a wallet
  delivered after first composition flips to KeepCurrent, preventing a silent
  key rotation on save in the cold-open race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXRAunSJS2dBx7B79qTMew
This commit is contained in:
Claude
2026-06-17 22:32:15 +00:00
parent d56f3a675d
commit e8fdbc5532
3 changed files with 21 additions and 5 deletions
@@ -942,6 +942,14 @@ class AccountSettings(
fun updateNutzapInfo(newNutzapInfo: NutzapInfoEvent?) {
if (newNutzapInfo == null || newNutzapInfo.tags.isEmpty()) return
// A mints-less kind:10019 is the "stop receiving nutzaps" tombstone
// (an empty replacement carrying only an `alt` tag). Don't restore it
// on next launch — backing it up would undo clearNutzapInfo() once the
// empty event round-trips back through LocalCache.
if (newNutzapInfo.mints().isEmpty()) {
clearNutzapInfo()
return
}
if (backupNutzapInfo?.id != newNutzapInfo.id) {
backupNutzapInfo = newNutzapInfo
saveAccountSettings()
@@ -563,6 +563,11 @@ class CashuWalletState(
// Any wallet event resolves the "discovering" state — whether it
// came from cache backfill or a fresh relay delivery.
_discovering.value = false
// The NUT-13 seed is derived from the wallet's P2PK key. A new
// kind:17375 may carry a rotated key (our own recreateNutzapKey, or
// a rotation from another client), so drop the cached seed and let
// ensureSeed re-derive from whatever key the live event now holds.
cachedSeed = null
walletEventInternal?.let { evt ->
_mints.value =
runCatching { evt.mints(signer) }
@@ -838,10 +843,9 @@ class CashuWalletState(
p2pkPrivkeyHex = manualPrivkeyHex?.takeIf { it.isNotBlank() },
nutzapRelays = outboxRelaysFlow.value.toList(),
)
// The NUT-13 seed is derived from the P2PK key, which just changed —
// drop the cache so the next mint op re-derives from the new key
// (re-populated lazily by ensureSeed once the new kind:17375 lands).
cachedSeed = null
// The NUT-13 seed (derived from the P2PK key) is invalidated by
// applyEvents when the new kind:17375 round-trips in, so it re-derives
// from the rotated key. No need to reset it here.
}
// ============================================================
@@ -99,7 +99,11 @@ fun AddCashuWalletScreen(
// suggestions on first open instead of waiting for the next relay
// round-trip. Cheap (one sweep over the existing cache); idempotent.
LaunchedEffect(Unit) { LocalCache.ensureMintDirectoryBackfilled() }
var keyMode by remember {
// Keyed on isEditMode so that if the wallet is delivered AFTER this screen
// first composes (existingWallet null → non-null), keyMode flips to
// KeepCurrent. Otherwise a cold open with an undelivered wallet would keep
// AutoGenerate and silently rotate the key on save, orphaning nutzaps.
var keyMode by remember(isEditMode) {
mutableStateOf(
if (isEditMode) CashuWalletViewModel.P2pkKeyMode.KeepCurrent else CashuWalletViewModel.P2pkKeyMode.AutoGenerate,
)