fix(desktop): fix NWC relay connection, disconnect crash, and balance error handling

- 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) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-22 06:23:05 +03:00
co-authored by Claude Opus 4.6
parent 704c8a3e87
commit 746dab51b4
4 changed files with 86 additions and 5 deletions
@@ -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 |
@@ -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.
@@ -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) {
@@ -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)