From c6757a2fae88bd10be2e926ac78d0c06deabd327 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 23:37:38 +0000 Subject: [PATCH] fix(cashu): make AddCashuWallet screen also work as Edit, preserve P2PK key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: tapping the Edit pencil on CashuWalletScreen routed to AddCashuWalletScreen, which always started with an empty mints list and auto-generated a fresh P2PK key on save. Net effect: editing a wallet silently wiped the mint list and invalidated any inbound nutzaps locked to the previous key. Changes: * AddCashuWalletScreen now detects edit mode (walletEvent != null) and pre-fills the mints list from CashuWalletState.mints on entry. Subsequent state updates (e.g. mints arriving from relays mid-edit) merge in via LaunchedEffect(existingMints). * P2PK key handling is now an explicit 3-way radio (KeepCurrent / AutoGenerate / Manual) with KeepCurrent as the edit-mode default. AutoGenerate in edit mode shows a destructive-action warning. Create mode hides KeepCurrent and defaults to AutoGenerate. * CashuWalletState.exportP2pkPrivkeyHex() — suspending accessor used by the VM when KeepCurrent is selected. Necessary because remote / NIP-46 signers need a round-trip to decrypt the wallet's NIP-44 content. * CashuWalletViewModel.saveWallet(mints, keyMode, manualPrivkey?) — replaces the old (autoGenPrivkey, manualPrivkey) shape with the explicit P2pkKeyMode enum so the screen and VM agree on intent instead of inferring it from a boolean. * Title shows "Edit Cashu wallet" + button reads "Save changes" when editing an existing wallet. * Vertical scroll added so radio + manual key field don't push the Save button off-screen on small devices. Both playDebug + fdroidDebug compile clean; 24/24 jvm tests still pass. https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP --- .../model/nip60Cashu/CashuWalletState.kt | 10 ++ .../loggedIn/wallet/AddCashuWalletScreen.kt | 109 +++++++++++++++--- .../loggedIn/wallet/CashuWalletViewModel.kt | 39 ++++++- amethyst/src/main/res/values/strings.xml | 6 +- 4 files changed, 138 insertions(+), 26 deletions(-) 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 e78530e32e..02d5790204 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 @@ -143,6 +143,16 @@ class CashuWalletState( .toHexKey() } + /** + * Read the wallet's P2PK private key. Used by the edit-wallet flow to + * preserve the same nutzap key when only mints are being changed — + * regenerating would orphan any inbound nutzaps locked to the old key. + * + * The signer round-trip means this is suspending and can fail (remote / + * external signers may reject the decrypt). Callers should handle null. + */ + suspend fun exportP2pkPrivkeyHex(): String? = walletPrivkeyHex() + private suspend fun walletPrivkeyHex(): String? = _walletEvent.value?.let { evt -> runCatching { evt.privkey(signer) }.getOrNull() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt index bea7afe37c..c2bc1a462c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -30,6 +29,9 @@ import androidx.compose.foundation.layout.height 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.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -40,8 +42,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -73,12 +75,31 @@ fun AddCashuWalletScreen( val viewModel: CashuWalletViewModel = viewModel() LaunchedEffect(Unit) { viewModel.init(accountViewModel) } + val existingWallet by viewModel.walletEvent.collectAsState() + val existingMints by viewModel.mints.collectAsState() + val isEditMode = existingWallet != null + val mints = remember { mutableStateListOf() } var mintInput by remember { mutableStateOf("") } - var autoGenPrivkey by remember { mutableStateOf(true) } + var keyMode by remember { + 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. + LaunchedEffect(existingMints) { + if (existingMints.isNotEmpty()) { + val current = mints.toSet() + existingMints.forEach { if (it !in current) mints.add(it) } + } + } + LaunchedEffect(createState) { if (createState is CashuWalletCreateState.Success) { nav.popBack() @@ -89,7 +110,13 @@ fun AddCashuWalletScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringRes(R.string.wallet_add_cashu_title)) }, + title = { + Text( + stringRes( + if (isEditMode) R.string.wallet_edit_cashu_title else R.string.wallet_add_cashu_title, + ), + ) + }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { Icon( @@ -106,6 +133,7 @@ fun AddCashuWalletScreen( Modifier .fillMaxSize() .padding(padding) + .verticalScroll(rememberScrollState()) .padding(16.dp), ) { Text( @@ -162,9 +190,7 @@ fun AddCashuWalletScreen( ) Spacer(modifier = Modifier.width(8.dp)) OutlinedButton( - onClick = { - viewModel.pingMint(mintInput.trim().trimEnd('/')) - }, + onClick = { viewModel.pingMint(mintInput.trim().trimEnd('/')) }, enabled = mintInput.isNotBlank() && pingState !is MintPingState.Pinging, ) { if (pingState is MintPingState.Pinging) { @@ -229,16 +255,26 @@ fun AddCashuWalletScreen( ) Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringRes(R.string.cashu_p2pk_autogen)) - Switch(checked = autoGenPrivkey, onCheckedChange = { autoGenPrivkey = it }) + if (isEditMode) { + P2pkRadio( + label = stringRes(R.string.cashu_p2pk_keep_current), + selected = keyMode == CashuWalletViewModel.P2pkKeyMode.KeepCurrent, + onSelect = { keyMode = CashuWalletViewModel.P2pkKeyMode.KeepCurrent }, + ) } + P2pkRadio( + label = stringRes(R.string.cashu_p2pk_autogen), + sub = if (isEditMode) stringRes(R.string.cashu_p2pk_autogen_warning_edit) else null, + 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 (!autoGenPrivkey) { + if (keyMode == CashuWalletViewModel.P2pkKeyMode.Manual) { Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = manualPrivkey, @@ -266,16 +302,51 @@ fun AddCashuWalletScreen( onClick = { viewModel.saveWallet( mints = mints.toList(), - autoGenPrivkey = autoGenPrivkey, - manualPrivkey = manualPrivkey.takeIf { !autoGenPrivkey }, + keyMode = keyMode, + manualPrivkey = manualPrivkey.takeIf { keyMode == CashuWalletViewModel.P2pkKeyMode.Manual }, ) }, enabled = mints.isNotEmpty() && - createState !is CashuWalletCreateState.Saving, + createState !is CashuWalletCreateState.Saving && + (keyMode != CashuWalletViewModel.P2pkKeyMode.Manual || manualPrivkey.isNotBlank()), modifier = Modifier.fillMaxWidth(), ) { - Text(stringRes(R.string.wallet_save)) + Text( + stringRes( + if (isEditMode) R.string.wallet_save_changes else R.string.wallet_save, + ), + ) + } + } + } +} + +@Composable +private fun P2pkRadio( + label: String, + selected: Boolean, + onSelect: () -> Unit, + sub: String? = null, +) { + 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)) + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyMedium) + if (sub != null) { + Text( + sub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index 740092dd2b..c25f58c703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -190,9 +190,21 @@ class CashuWalletViewModel : ViewModel() { _mintPingState.value = MintPingState.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, + + /** Generate a fresh random key — invalidates inbound nutzaps locked to the old one. */ + AutoGenerate, + + /** Use a user-pasted hex key. */ + Manual, + } + fun saveWallet( mints: List, - autoGenPrivkey: Boolean, + keyMode: P2pkKeyMode, manualPrivkey: String? = null, ) { val vm = accountViewModel ?: return @@ -202,15 +214,30 @@ class CashuWalletViewModel : ViewModel() { _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 + } _createState.value = CashuWalletCreateState.Saving vm.launchSigner { try { - val privkey = - when { - autoGenPrivkey -> null // ops generates one - !manualPrivkey.isNullOrBlank() -> manualPrivkey.trim() - else -> null + 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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e5a1ec4f3c..126d64f11d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1884,8 +1884,12 @@ Edit wallet Nutzap key (advanced) A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key. - Auto-generate + Generate a new key + Will invalidate any pending nutzaps locked to the current key. + Keep current key P2PK private key (hex) + Edit Cashu wallet + Save changes Receive Send LN Send Token