Merge pull request #3380 from vitorpamplona/claude/adoring-babbage-stzzck

Auto-save Cashu wallet on mint add/remove, remove manual key picker
This commit is contained in:
Vitor Pamplona
2026-06-26 11:16:48 -04:00
committed by GitHub
3 changed files with 92 additions and 165 deletions
@@ -33,10 +33,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -48,7 +46,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -99,22 +96,16 @@ 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() }
// 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,
)
}
var manualPrivkey by remember { mutableStateOf("") }
val createState by viewModel.createState.collectAsState()
// Pre-fill the mints list with the existing wallet's mints whenever we
// enter edit mode (or the existing mints update). Without this, hitting
// the Edit button silently wipes the user's mint list because the local
// `mints` state holder starts empty.
//
// Mutates `mints` directly rather than going through `publishMints` so the
// pre-fill never re-publishes the wallet — only genuine user add/remove
// actions do.
LaunchedEffect(existingMints) {
if (existingMints.isNotEmpty()) {
val current = mints.toSet()
@@ -122,13 +113,6 @@ fun AddCashuWalletScreen(
}
}
LaunchedEffect(createState) {
if (createState is CashuWalletCreateState.Success) {
nav.popBack()
viewModel.resetCreateState()
}
}
Scaffold(
topBar = {
TopAppBar(
@@ -196,7 +180,10 @@ fun AddCashuWalletScreen(
)
}
}
IconButton(onClick = { mints.removeAt(index) }) {
IconButton(onClick = {
mints.removeAt(index)
viewModel.publishMints(mints.toList())
}) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.cashu_remove_mint),
@@ -277,6 +264,7 @@ fun AddCashuWalletScreen(
mints.add(trimmed)
mintInput = ""
viewModel.resetMintPing()
viewModel.publishMints(mints.toList())
}
},
enabled = mintInput.isNotBlank(),
@@ -340,6 +328,7 @@ fun AddCashuWalletScreen(
if (trimmed.isNotEmpty() && trimmed !in mints) {
mints.add(trimmed)
viewModel.resetMintPing()
viewModel.publishMints(mints.toList())
}
},
)
@@ -370,46 +359,29 @@ fun AddCashuWalletScreen(
else -> Unit
}
// The nutzap (P2PK) key is only chosen at creation. In edit mode
// keyMode stays KeepCurrent so saving never rotates the key — the
// (rare, destructive) rotation lives in the settings Danger Zone
// instead, so editing mints can't orphan inbound nutzaps.
if (!isEditMode) {
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringRes(R.string.cashu_p2pk_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.cashu_p2pk_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// No Save button: the wallet's kind:17375 + kind:10019 are
// published the moment the first mint is added and re-published on
// every later add/remove (see CashuWalletViewModel.publishMints).
// This line tells the user the screen auto-saves and that the
// nutzap key is generated for them — the old explicit key picker is
// gone; advanced key rotation lives in the settings Danger Zone.
Text(
text = stringRes(R.string.cashu_wallet_autosaves),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (createState is CashuWalletCreateState.Saving) {
Spacer(modifier = Modifier.height(8.dp))
P2pkRadio(
label = stringRes(R.string.cashu_p2pk_autogen),
selected = keyMode == CashuWalletViewModel.P2pkKeyMode.AutoGenerate,
onSelect = { keyMode = CashuWalletViewModel.P2pkKeyMode.AutoGenerate },
)
P2pkRadio(
label = stringRes(R.string.cashu_p2pk_manual_label),
selected = keyMode == CashuWalletViewModel.P2pkKeyMode.Manual,
onSelect = { keyMode = CashuWalletViewModel.P2pkKeyMode.Manual },
)
if (keyMode == CashuWalletViewModel.P2pkKeyMode.Manual) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = manualPrivkey,
onValueChange = { manualPrivkey = it },
label = { Text(stringRes(R.string.cashu_p2pk_manual_label)) },
placeholder = { Text("hex…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringRes(R.string.cashu_wallet_saving),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -423,57 +395,10 @@ fun AddCashuWalletScreen(
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
viewModel.saveWallet(
mints = mints.toList(),
keyMode = keyMode,
manualPrivkey = manualPrivkey.takeIf { keyMode == CashuWalletViewModel.P2pkKeyMode.Manual },
)
},
enabled =
mints.isNotEmpty() &&
createState !is CashuWalletCreateState.Saving &&
(keyMode != CashuWalletViewModel.P2pkKeyMode.Manual || manualPrivkey.isNotBlank()),
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringRes(
if (isEditMode) R.string.wallet_save_changes else R.string.wallet_save,
),
)
}
}
}
}
@Composable
private fun P2pkRadio(
label: String,
selected: Boolean,
onSelect: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onSelect)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onSelect)
Spacer(modifier = Modifier.width(8.dp))
Text(
label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
/**
* Mint directory autocomplete rendered inline under the mint input.
* Each row uses [MintDirectoryRow] so the user sees follower avatars
@@ -39,14 +39,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
sealed class CashuWalletCreateState {
data object Idle : CashuWalletCreateState()
data object Saving : CashuWalletCreateState()
data object Success : CashuWalletCreateState()
data class Error(
val message: String,
) : CashuWalletCreateState()
@@ -448,70 +448,70 @@ class CashuWalletViewModel : ViewModel() {
_restoreState.value = RestoreFlowState.Idle
}
/** What to do with the wallet's P2PK key during a save. */
enum class P2pkKeyMode {
/** Keep the existing key from the on-cache wallet (edit only). */
KeepCurrent,
/**
* Serialises [publishMints] so two near-simultaneous mint adds can't both
* reach [CashuWalletOps.publishWalletEvents] before the first has cached
* its key. Without it, the second add — firing before the freshly created
* kind:17375 round-trips back into `_walletEvent` — would read a null key
* and generate a *second* one, rotating the P2PK key and orphaning any
* inbound nutzaps locked to the first.
*/
private val publishMutex = Mutex()
/** Generate a fresh random key — invalidates inbound nutzaps locked to the old one. */
AutoGenerate,
/**
* The P2PK key in use for the wallet currently being edited on screen.
* Set on the first successful publish (inside [publishMutex]); reused by
* every later add/remove so the nutzap key stays put across changes.
*/
private var sessionPrivkeyHex: String? = null
/** Use a user-pasted hex key. */
Manual,
}
fun saveWallet(
mints: List<String>,
keyMode: P2pkKeyMode,
manualPrivkey: String? = null,
) {
/**
* Publish (or re-publish) the wallet's kind:17375 + kind:10019 with the
* given mint list. The Add/Edit screen has no Save button — the wallet is
* created the instant the first mint is added and kept in sync on every
* subsequent add/remove, so this runs on each list change.
*
* Key handling, in priority order:
* 1. [sessionPrivkeyHex] — the key from a publish we already did this
* session (covers rapid successive adds before the event round-trips).
* 2. [CashuWalletState.exportP2pkPrivkeyHex] — the existing wallet's key
* when editing a wallet that was already on cache when the screen
* opened.
* 3. null → [CashuWalletOps.publishWalletEvents] generates a fresh key
* (a genuinely new wallet).
*
* An empty list is a no-op: a NIP-60 wallet must advertise at least one
* mint, so removing the last mint leaves the previously published config
* untouched rather than publishing an empty, unusable wallet.
*/
fun publishMints(mints: List<String>) {
val vm = accountViewModel ?: return
val acc = account ?: return
if (mints.isEmpty()) {
_createState.value = CashuWalletCreateState.Error("Add at least one mint")
return
}
if (keyMode == P2pkKeyMode.Manual && manualPrivkey.isNullOrBlank()) {
_createState.value = CashuWalletCreateState.Error("Paste a P2PK private key")
return
}
if (mints.isEmpty()) return
_createState.value = CashuWalletCreateState.Saving
vm.launchSigner {
try {
val privkey: String? =
when (keyMode) {
P2pkKeyMode.KeepCurrent -> {
val existing = state.exportP2pkPrivkeyHex()
if (existing == null) {
_createState.value =
CashuWalletCreateState.Error(
"Could not read the existing wallet key. " +
"Use auto-generate or paste a key instead.",
)
return@launchSigner
}
existing
}
P2pkKeyMode.AutoGenerate -> null // ops generates one
P2pkKeyMode.Manual -> manualPrivkey?.trim()
}
ops.publishWalletEvents(
mints = mints,
p2pkPrivkeyHex = privkey,
// Advertise our NIP-65 inbox relays as the nutzap relays,
// so senders publish kind:9321 where we read incoming
// events (NIP-65 outbox model). The kind:10019 default
// copies the inbox relay list, not outbox; the wallet
// still subscribes to a wider inbox + DM + tags set.
nutzapRelays =
acc.notificationRelays.flow.value
.toList(),
)
_createState.value = CashuWalletCreateState.Success
} catch (e: Exception) {
_createState.value = CashuWalletCreateState.Error(describeMintError(e))
publishMutex.withLock {
try {
val privkey = sessionPrivkeyHex ?: state.exportP2pkPrivkeyHex()
val created =
ops.publishWalletEvents(
mints = mints,
p2pkPrivkeyHex = privkey,
// Advertise our NIP-65 inbox relays as the nutzap relays,
// so senders publish kind:9321 where we read incoming
// events (NIP-65 outbox model). The kind:10019 default
// copies the inbox relay list, not outbox; the wallet
// still subscribes to a wider inbox + DM + tags set.
nutzapRelays =
acc.notificationRelays.flow.value
.toList(),
)
sessionPrivkeyHex = created.p2pkPrivkeyHex
_createState.value = CashuWalletCreateState.Idle
} catch (e: Exception) {
_createState.value = CashuWalletCreateState.Error(describeMintError(e))
}
}
}
}
+2
View File
@@ -2014,6 +2014,8 @@
<string name="cashu_remove_mint">Remove mint</string>
<string name="cashu_add_mint">Add mint</string>
<string name="cashu_history">History</string>
<string name="cashu_wallet_autosaves">Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint.</string>
<string name="cashu_wallet_saving">Saving…</string>
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
<string name="cashu_p2pk_explainer">A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key.</string>
<string name="cashu_p2pk_autogen">Generate a new key</string>