fix(cashu): make AddCashuWallet screen also work as Edit, preserve P2PK key

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
This commit is contained in:
Claude
2026-05-27 15:17:40 +00:00
parent bfd00ccc34
commit c6757a2fae
4 changed files with 138 additions and 26 deletions
@@ -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()
@@ -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<String>() }
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,
)
}
}
}
@@ -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<String>,
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,
+5 -1
View File
@@ -1884,8 +1884,12 @@
<string name="cashu_edit_wallet">Edit wallet</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">Auto-generate</string>
<string name="cashu_p2pk_autogen">Generate a new key</string>
<string name="cashu_p2pk_autogen_warning_edit">Will invalidate any pending nutzaps locked to the current key.</string>
<string name="cashu_p2pk_keep_current">Keep current key</string>
<string name="cashu_p2pk_manual_label">P2PK private key (hex)</string>
<string name="wallet_edit_cashu_title">Edit Cashu wallet</string>
<string name="wallet_save_changes">Save changes</string>
<string name="cashu_action_receive">Receive</string>
<string name="cashu_action_send_ln">Send LN</string>
<string name="cashu_action_send_token">Send Token</string>