mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
co-authored by
Claude Opus 4.6
parent
704c8a3e87
commit
746dab51b4
@@ -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 |
|
||||
+20
@@ -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.
|
||||
|
||||
+6
-2
@@ -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) {
|
||||
|
||||
+10
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user