From 6b3e216ea37f8d60e8cbdb1969c090ef56957ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 23:00:06 +0000 Subject: [PATCH 1/2] fix: let Save commit a typed-but-unadded Cashu mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When adding a Cashu wallet, the mint URL typed into the input field was only committed to the mint list when the user pressed the "+" button. The Save button was gated on the list being non-empty, so a user who typed a mint, chose a key option, and tried to Save found the button disabled with no obvious reason — they had to discover the "+" button first. Save now folds a pending mint from the input field into the list, making "+" optional. The button also enables when the input is non-blank. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VX5wkwXbvoQpadALx1ZVjk --- .../loggedIn/wallet/AddCashuWalletScreen.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 db7ca683ed..e8982eebf2 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 @@ -426,16 +426,28 @@ fun AddCashuWalletScreen( Spacer(modifier = Modifier.height(24.dp)) + // A mint typed into the input but not yet committed with the "+" + // button. Folding it into the save makes "+" optional: the user can + // type a mint and hit Save directly instead of being blocked by a + // disabled button with no obvious reason. + val pendingMint = mintInput.trim().trimEnd('/') + Button( onClick = { + val allMints = + if (pendingMint.isNotEmpty() && pendingMint !in mints) { + mints.toList() + pendingMint + } else { + mints.toList() + } viewModel.saveWallet( - mints = mints.toList(), + mints = allMints, keyMode = keyMode, manualPrivkey = manualPrivkey.takeIf { keyMode == CashuWalletViewModel.P2pkKeyMode.Manual }, ) }, enabled = - mints.isNotEmpty() && + (mints.isNotEmpty() || pendingMint.isNotEmpty()) && createState !is CashuWalletCreateState.Saving && (keyMode != CashuWalletViewModel.P2pkKeyMode.Manual || manualPrivkey.isNotBlank()), modifier = Modifier.fillMaxWidth(), From b4d39a7e52af6703668d4950898b08d80456f562 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 15:09:20 +0000 Subject: [PATCH 2/2] feat: auto-publish Cashu wallet on mint add/remove, drop Save button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Add/Edit Cashu wallet screen no longer has a Save button. The NIP-60 wallet (kind:17375) and nutzap info (kind:10019) are now published the instant the first mint is added, and re-published on every later add or remove — so the list on screen always matches what's on relays. This removes the confusing two-step flow where a typed/selected mint plus a chosen key still left Save disabled until the user discovered the "+" button. The explicit P2PK key picker (auto-generate / paste) is gone from this screen: a nutzap key is generated automatically on first creation. Advanced key import/rotation still lives in the settings Danger Zone. Key safety: publishMints reuses the wallet's existing P2PK key on every re-publish. The first publish's key is cached in the ViewModel and guarded by a mutex, so rapid successive adds — firing before the new kind:17375 round-trips back through LocalCache — can't generate a second key and rotate it, which would orphan inbound nutzaps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VX5wkwXbvoQpadALx1ZVjk --- .../loggedIn/wallet/AddCashuWalletScreen.kt | 149 ++++-------------- .../loggedIn/wallet/CashuWalletViewModel.kt | 118 +++++++------- amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 92 insertions(+), 177 deletions(-) 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 e8982eebf2..72cf5b5778 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 @@ -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,69 +395,10 @@ fun AddCashuWalletScreen( style = MaterialTheme.typography.bodySmall, ) } - - Spacer(modifier = Modifier.height(24.dp)) - - // A mint typed into the input but not yet committed with the "+" - // button. Folding it into the save makes "+" optional: the user can - // type a mint and hit Save directly instead of being blocked by a - // disabled button with no obvious reason. - val pendingMint = mintInput.trim().trimEnd('/') - - Button( - onClick = { - val allMints = - if (pendingMint.isNotEmpty() && pendingMint !in mints) { - mints.toList() + pendingMint - } else { - mints.toList() - } - viewModel.saveWallet( - mints = allMints, - keyMode = keyMode, - manualPrivkey = manualPrivkey.takeIf { keyMode == CashuWalletViewModel.P2pkKeyMode.Manual }, - ) - }, - enabled = - (mints.isNotEmpty() || pendingMint.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 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 c799ce232e..3055361c2a 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 @@ -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, - 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) { 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)) + } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e1376ea31c..794da8f986 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2019,6 +2019,8 @@ Remove mint Add mint History + Your wallet saves automatically as you add or remove mints. A nutzap key is created for you the first time you add a mint. + Saving… Nutzap key (advanced) A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key. Generate a new key