mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+10
@@ -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()
|
||||
|
||||
+90
-19
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-6
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user