From 746dab51b485f0b1d7a70796e9d3d8dcff4240fa Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 22 May 2026 06:23:05 +0300 Subject: [PATCH] fix(desktop): fix NWC relay connection, disconnect crash, and balance error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove premature ensureRelayConnected check — NostrClient connects on subscribe/publish via sendOrConnectAndSync - Fix disconnect crash: use appScope instead of rememberCoroutineScope to survive recomposition when nwcConnection goes null - Surface balance errors/timeouts as snackbars instead of silent swallow - Add ensureRelayConnected helper to RelayConnectionManager - Add Phase 2 embedded wallet research doc Co-Authored-By: Claude Opus 4.6 (1M context) --- ...6-05-21-embedded-wallet-phase2-research.md | 50 +++++++++++++++++++ .../desktop/network/RelayConnectionManager.kt | 20 ++++++++ .../amethyst/desktop/nwc/NwcPaymentHandler.kt | 8 ++- .../desktop/ui/wallet/WalletColumnScreen.kt | 13 +++-- 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md diff --git a/desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md b/desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md new file mode 100644 index 0000000000..bfc383b61f --- /dev/null +++ b/desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md @@ -0,0 +1,50 @@ +# Phase 2: Embedded Self-Custodial Wallet — Research Summary + +**Date:** 2026-05-21 +**Status:** Research complete, parked. Return after NWC parity (Phase 1) ships. + +## Context + +No desktop Nostr client has an embedded wallet. All use NWC. This would be a first. + +## Top Candidates (High Sovereignty) + +| | Breez SDK Spark | ldk-node-jvm | lightning-kmp | +|---|---|---|---| +| Sovereignty | Full | Full | Full | +| Architecture | No channels | Channels + LSPS2 | Single channel + splicing | +| KMP/JVM | KMP artifact (`breez-sdk-spark-kmp:0.7.10`) | JVM JAR (`ldk-node-jvm:0.7.0`) | KMP (`lightning-kmp:1.8.4`) | +| LSP lock-in | None | Any LSPS2 | ACINQ only | +| Embedding docs | Breez docs | Good | None | +| License | MIT | MIT/Apache-2.0 | Apache-2.0 | + +### Recommended path + +1. **Spike Breez SDK Spark** — verify KMP artifact works on JVM desktop (not just Android) +2. **Fallback: ldk-node-jvm** — proven JVM, any LSP, well-documented +3. **Skip: lightning-kmp** — ACINQ LSP lock-in, no embedding docs + +### Eliminated + +- phoenixd: subprocess, no Windows native +- Breez SDK Liquid: Android-only bindings +- Greenlight: weak JVM support +- Cashu: not self-custodial (mint trust) + +## Open Questions + +1. Does `breez-sdk-spark-kmp` include JVM desktop native libs? +2. Spark fee economics for small zaps (10-100 sats)? +3. Does `ldk-node-jvm` bundle macOS arm64/x64 + Linux x64 natives? +4. Which LSPS2 LSPs are publicly available? +5. Would ACINQ accept third-party lightning-kmp clients? + +## Nostr App Landscape + +| App | Wallet | Type | +|-----|--------|------| +| Primal | Strike (custodial), maybe migrating to Spark | Built-in | +| 0xchat | cashu-dart | Cashu ecash | +| YakiHonne | Cashu + NWC | Dual | +| Amethyst Android | NWC + Cashu token parsing | External | +| All desktop clients | NWC only | External | diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 2a3944da16..68a0f2997f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -144,6 +144,26 @@ open class RelayConnectionManager( publish(event, connected) } + /** + * Waits for a relay to appear in connectedRelays, adding it if needed. + * Returns true if connected within the timeout, false otherwise. + */ + suspend fun ensureRelayConnected( + relay: NormalizedRelayUrl, + timeoutMs: Long = 10_000, + ): Boolean { + if (relay in connectedRelays.value) return true + if (relay !in availableRelays.value) { + updateRelayStatus(relay) { it.copy(connected = false, error = null) } + } + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (relay in connectedRelays.value) return true + delay(200) + } + return relay in connectedRelays.value + } + /** * Sends an event to a specific relay (for NWC). * Adds the relay if not already in the list. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt index 636e723020..7171e85b40 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt @@ -209,17 +209,19 @@ class NwcPaymentHandler( timeoutMs: Long = 30_000, ): BalanceResult { val secret = nwcConnection.secret ?: return BalanceResult.Error("NWC connection has no secret") + val nwcSigner = NostrSignerInternal(KeyPair(secret.hexToByteArray())) val client = Nip47Client.fromNip47URI(nwcConnection) val requestEvent = client.getBalance() return withTimeoutOrNull(timeoutMs) { - // Subscribe BEFORE publishing to avoid race with fast wallet responses waitForGenericResponse( requestId = requestEvent.id, nwcConnection = nwcConnection, nwcSigner = nwcSigner, - onSubscribed = { relayManager.publishToRelay(nwcConnection.relayUri, requestEvent) }, + onSubscribed = { + relayManager.publishToRelay(nwcConnection.relayUri, requestEvent) + }, ) { response -> when (response) { is GetBalanceSuccessResponse -> { @@ -261,6 +263,7 @@ class NwcPaymentHandler( timeoutMs: Long = 30_000, ): InvoiceResult { val secret = nwcConnection.secret ?: return InvoiceResult.Error("NWC connection has no secret") + val nwcSigner = NostrSignerInternal(KeyPair(secret.hexToByteArray())) val client = Nip47Client.fromNip47URI(nwcConnection) val requestEvent = client.makeInvoice(amountMsats, description) @@ -319,6 +322,7 @@ class NwcPaymentHandler( subId = subId, filters = listOf(filter), onEvent = { event, _ -> + println("NWC rpc event received: kind=${event.kind} id=${event.id.take(8)} from=${event.pubKey.take(8)}") if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) { @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index 6d8ec28dc4..14774d24f7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -112,7 +112,15 @@ fun WalletColumnScreen( balanceSats = result.balanceMsats / 1000 } - else -> {} + is NwcPaymentHandler.BalanceResult.Error -> { + println("NWC balance error: ${result.message}") + snackbarHostState.showSnackbar("Balance error: ${result.message}") + } + + is NwcPaymentHandler.BalanceResult.Timeout -> { + println("NWC balance timeout") + snackbarHostState.showSnackbar("Balance request timed out") + } } isLoadingBalance = false } @@ -204,10 +212,9 @@ fun WalletColumnScreen( ) TextButton(onClick = { - scope.launch { + appScope.launch { accountManager.clearNwcConnection(account.npub) balanceSats = null - snackbarHostState.showSnackbar("Wallet disconnected") } }) { Text("Disconnect", color = MaterialTheme.colorScheme.error)