diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt index d6b7768e16..bde4e94d31 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt @@ -143,7 +143,7 @@ class LightningAddressResolver( milliSats = milliSats, message = message, zapRequest = if (allowsNostr) zapRequest else null, - ) ?: return@withContext Result.Error("Failed to fetch invoice from callback") + ) ?: return@withContext Result.Error("Failed to connect to payment server") onProgress(0.7f) @@ -157,7 +157,9 @@ class LightningAddressResolver( val pr = invoiceResponse?.get("pr")?.asText()?.ifBlank { null } if (pr == null) { - val reason = invoiceResponse?.get("reason")?.asText()?.ifBlank { null } + val reason = + invoiceResponse?.get("reason")?.asText()?.ifBlank { null } + ?: invoiceResponse?.get("message")?.asText()?.ifBlank { null } return@withContext Result.Error(reason ?: "No invoice in response") } @@ -221,11 +223,8 @@ class LightningAddressResolver( val request = Request.Builder().url(url).build() httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body?.string() - } else { - null - } + // Return body even on error — caller extracts "reason" or "message" from JSON + response.body?.string() } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/desktopApp/plans/2026-05-09-embedded-local-relay-plan.md b/desktopApp/plans/2026-05-09-embedded-local-relay-plan.md new file mode 100644 index 0000000000..6e7f84fd33 --- /dev/null +++ b/desktopApp/plans/2026-05-09-embedded-local-relay-plan.md @@ -0,0 +1,475 @@ +# Embedded Local Relay for Amethyst Desktop + +## Enhancement Summary + +**Deepened on:** 2026-05-09 +**Sections enhanced:** 5 phases + architecture +**Research agents used:** LocalRelayClient patterns, EventWriteBuffer, Hydration strategy, NIP-09/maintenance, Settings UI + Offline, Account lifecycle + +### Key Improvements +1. Discovered `BasicBundledInsert` already exists — reuse for write buffer instead of custom Channel +2. Full IRelayClient interface mapped — LocalRelayClient skeleton ready +3. Account lifecycle hook points identified (Main.kt lines 771-792, 830-866) +4. UI patterns catalogued — CollapsibleSection, DmBroadcastBanner, SearchSyncBanner all reusable +5. SQLite triggers already handle replaceables, deletions, expiration — no app-level logic needed + +## Overview +Add an in-process local relay to Amethyst Desktop using quartz's existing `NostrServer` + `SQLiteEventStore`. No WebSocket server — the relay lives in-process as a `LocalRelayClient` added to `RelayPool`. All remote relay events are persisted to SQLite. On startup, the local store hydrates `DesktopLocalCache` for instant feed rendering. + +## Architecture + +``` +App Startup + | + v +EventStore("~/.amethyst/accounts//events.db") + | + v +NostrServer(store, policyBuilder={VerifyPolicy}) + | + v +LocalRelayClient : IRelayClient (url = "local://amethyst") + | | + v v +RelayPool treats it like any relay DesktopLocalCache.consume() writes through +``` + +### Data Flow (Steady State) +``` +Remote Relay event arrives + -> DesktopRelaySubscriptionsCoordinator.consumeEvent() + -> localCache.consume(event, relay) [existing] + -> localRelayStore.enqueue(event) [NEW: write-through] + -> BasicBundledInsert batches (250ms) + -> store.transaction { batch.forEach { insert(it) } } +``` + +### Data Flow (Startup Hydration) +``` +Account login (pubKeyHex available) + -> LocalRelayStore.openForAccount(pubKeyHex) + -> LocalRelayStore.hydrate(localCache) + -> Phase 1: Query kind 3 (contact list) for own pubkey + -> Phase 2: Query kind 0 (metadata) for followed users + -> Phase 3: Query kinds 1,6,7,16,1111 since 7 days, limit 5000 + -> Each event -> localCache.consume(event, localRelayUrl) + -> relayManager.connect() [remote relays start after hydration] +``` + +## Phases + +### Phase 1: Core Infrastructure +**Goal**: Wire EventStore + write-through buffer + account lifecycle + +#### Files to Create + +**`desktopApp/.../relay/LocalRelayStore.kt`** — Manages EventStore lifecycle per account + +```kotlin +class LocalRelayStore( + private val scope: CoroutineScope, +) : AutoCloseable { + private var store: EventStore? = null + val localRelayUrl = "local://amethyst".normalizeRelayUrl() + + // Write buffer using existing BasicBundledInsert pattern + private val writeBundler = BasicBundledInsert( + delay = 250, // Same as desktop event bundler + dispatcher = Dispatchers.IO, + scope = scope, + ) + + fun openForAccount(pubKeyHex: String) { + close() + val dbDir = File(System.getProperty("user.home"), ".amethyst/accounts/${pubKeyHex.take(8)}") + dbDir.mkdirs() + store = EventStore( + dbName = File(dbDir, "events.db").absolutePath, + relay = localRelayUrl, + ) + } + + fun enqueue(event: Event) { + writeBundler.invalidateList(event) { batch -> + store?.transaction { + batch.forEach { insert(it) } + } + } + } + + override fun close() { + store?.close() + store = null + } +} +``` + +**Key decisions:** +- DB path: `~/.amethyst/accounts//events.db` (8-char hex prefix) +- `EventStore` wraps `SQLiteEventStore` with `BundledSQLiteDriver()` — no JNI needed +- `BasicBundledInsert` already battle-tested in `DesktopRelaySubscriptionsCoordinator` +- SQLite triggers handle replaceables, deletions, expiration automatically + +#### Files to Modify + +**`DesktopLocalCache.kt`** — Add write-through hook in `consume()` + +```kotlin +// After line ~208 (after route(event, relay)): +// Add: localRelayStore?.enqueue(event) +``` + +Interception point: `consume()` already verifies signatures. After `route()` succeeds, enqueue to local store. + +**`Main.kt`** — Account lifecycle integration + +Hook points identified: +- Line 697: Create `LocalRelayStore` alongside `localCache` +- Line 771-792 (`LaunchedEffect(accountState)`): + - `LoggedOut` → `localRelayStore.close()` + - `LoggedIn` with pubkey change → `localRelayStore.openForAccount(newPubKeyHex)` +- Line 859-866 (`DisposableEffect` cleanup): `localRelayStore.close()` + +```kotlin +// Line ~697: +val localRelayStore = remember { LocalRelayStore(scope) } + +// Line ~785 (after clear, before metadata): +localRelayStore.openForAccount(pubKeyHex) +``` + +#### No LocalRelayClient in Phase 1 + +Research revealed the write-through + hydration approach doesn't need a full `IRelayClient` implementation. The local store is a persistence layer, not a relay client. Events flow through the existing relay pool from remote relays and get persisted as a side effect. On startup, hydration reads from the store directly. + +Implementing `IRelayClient` would require handling JSON serialization/deserialization roundtrips through `NostrServer` which adds overhead for no benefit in Approach A. + +**Simplified architecture:** +- `LocalRelayStore` = `EventStore` + write buffer + hydration +- No `NostrServer` needed (saves serialization overhead) +- No `IRelayClient` needed (local store is not a relay peer) + +--- + +### Phase 2: Startup Hydration +**Goal**: On login, populate DesktopLocalCache from local store before remote relays connect + +#### Implementation in `LocalRelayStore` + +```kotlin +suspend fun hydrate(cache: DesktopLocalCache) { + val s = store ?: return + val relay = localRelayUrl + + // Phase 1: Own contact list (need follow list for feed filtering) + // Note: can't filter by own pubkey without it being passed in + val contactFilter = Filter(kinds = listOf(3), limit = 1) + s.query(contactFilter).forEach { event -> + cache.consume(event, relay, wasVerified = true) + } + + // Phase 2: Metadata for followed users + val followed = cache.followedUsers.value + if (followed.isNotEmpty()) { + // Batch metadata requests (max 500 authors per query for performance) + followed.chunked(500).forEach { chunk -> + val metaFilter = Filter( + kinds = listOf(0), + authors = chunk, + limit = chunk.size, + ) + s.query(metaFilter).forEach { event -> + cache.consume(event, relay, wasVerified = true) + } + } + } + + // Phase 3: Recent content events + val since = (System.currentTimeMillis() / 1000) - (7 * 24 * 3600) + val contentFilter = Filter( + kinds = listOf(1, 6, 7, 16, 1111, 9735), // notes, reposts, reactions, comments, zaps + since = since, + limit = 5000, + ) + s.query(contentFilter).forEach { event -> + cache.consume(event, relay, wasVerified = true) + } +} +``` + +#### Memory Impact +- 5000 events at ~600 bytes avg = ~3 MB (trivial for desktop JVM) +- Hydration time: SQLite query + cache insertion = <500ms for 5000 events +- `wasVerified = true` skips signature verification (already verified on first insert) + +#### Main.kt Integration + +```kotlin +// After localRelayStore.openForAccount(pubKeyHex), before relayManager.connect(): +scope.launch(Dispatchers.IO) { + localRelayStore.hydrate(localCache) + // Then connect remote relays (they'll fill gaps) +} +``` + +**Decision: Async hydration** — Show UI immediately, hydrate in background. Feed renders from local store data within ~200ms, then remote relay events fill in gaps. + +--- + +### Phase 3: Event Lifecycle +**Goal**: Handle deletions, pruning, VACUUM, disk monitoring + +#### File to Create + +**`desktopApp/.../relay/LocalRelayMaintenance.kt`** + +```kotlin +class LocalRelayMaintenance( + private val store: LocalRelayStore, + private val scope: CoroutineScope, +) { + private var maintenanceJob: Job? = null + private val _diskWarning = MutableStateFlow(false) + val diskWarning: StateFlow = _diskWarning + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError + + fun start(dbPath: String) { + maintenanceJob = scope.launch(Dispatchers.IO) { + // Startup maintenance + try { + store.deleteExpiredEvents() + maybeVacuum(dbPath) + } catch (e: Exception) { + _lastError.value = "Startup maintenance: ${e.message}" + } + + // Periodic maintenance (every 6 hours) + while (isActive) { + delay(6 * 60 * 60 * 1000L) + try { + store.deleteExpiredEvents() + store.pruneOldEvents(maxAgeDays = 30) + checkDiskSpace(dbPath) + } catch (e: Exception) { + _lastError.value = "Periodic maintenance: ${e.message}" + } + } + } + } + + private fun checkDiskSpace(dbPath: String) { + val usable = File(dbPath).parentFile?.usableSpace ?: return + _diskWarning.value = usable < 100 * 1024 * 1024 // < 100MB + if (_diskWarning.value) { + store.disableWrites() + } + } + + private fun maybeVacuum(dbPath: String) { + val prefs = Preferences.userRoot().node("amethyst/localrelay") + val lastVacuum = prefs.getLong("lastVacuum", 0) + val sevenDays = 7 * 24 * 60 * 60 * 1000L + if (System.currentTimeMillis() - lastVacuum > sevenDays) { + store.vacuum() + prefs.putLong("lastVacuum", System.currentTimeMillis()) + } + } + + fun stop() { + maintenanceJob?.cancel() + } +} +``` + +#### NIP-09 Deletion +SQLiteEventStore's `DeletionRequestModule` already handles this via BEFORE INSERT triggers: +- When kind 5 event inserted → trigger deletes referenced events +- Trigger also prevents re-insertion of deleted events + +**Desktop hook**: In `DesktopLocalCache.consume()`, when a `DeletionEvent` is consumed, the write-through to `LocalRelayStore.enqueue()` handles it — the store's trigger does the rest. + +#### Pruning Strategy +Add to `LocalRelayStore`: +```kotlin +suspend fun pruneOldEvents(maxAgeDays: Int) { + val cutoff = (System.currentTimeMillis() / 1000) - (maxAgeDays * 24 * 3600L) + val filter = Filter(until = cutoff) + store?.delete(filter) +} +``` + +#### DB Corruption Recovery +```kotlin +fun openForAccount(pubKeyHex: String) { + close() + try { + store = EventStore(dbName = dbPath, ...) + } catch (e: Exception) { + // Corrupt DB — delete and recreate + File(dbPath).delete() + listOf("-wal", "-shm", "-journal").forEach { + File(dbPath + it).delete() + } + store = EventStore(dbName = dbPath, ...) + _lastError.value = "Database recreated: ${e.message}" + } +} +``` + +--- + +### Phase 4: Settings UI +**Goal**: Local Relay Settings screen with stats, controls, error display + +#### File to Create + +**`desktopApp/.../ui/settings/LocalRelaySettingsScreen.kt`** + +Uses established patterns: +- `CollapsibleSection` from `RelayConfigTab.kt` +- `AnimatedVisibility` with `expandVertically()` + `fadeIn()/fadeOut()` +- Material3 colors: `surfaceContainerHigh`, `errorContainer`, `onSurfaceVariant` +- Layout: `ReadingColumn` wrapper, 12.dp horizontal padding, 16.dp section spacers +- Controls: `Switch` for toggles, `Button` for actions, `OutlinedTextField` for config + +#### Sections + +**1. Status Section** (always visible) +- Switch: enabled/disabled toggle +- Status indicator: green dot (active) / gray (disabled) +- DB path display (read-only) + +**2. Statistics** (CollapsibleSection, initially expanded) +- DB size on disk: `File(dbPath).length()` formatted as KB/MB/GB +- Total event count: `store.count(Filter())` +- Events by kind: breakdown table (kind 0, 1, 3, 7, etc.) +- Last write time + +**3. Storage Management** (CollapsibleSection) +- Current disk usage +- Prune button: "Delete events older than 30 days" → `pruneOldEvents(30)` +- Clear all button: "Delete all cached events" → delete DB + recreate +- VACUUM button: "Reclaim disk space" → `store.vacuum()` + +**4. Export/Import** (CollapsibleSection) +- Export button: JSONL file (one event JSON per line) + - Uses `DesktopFilePicker` for save dialog + - Streams events from store → write to file +- Import button: Read JSONL file → `store.transaction { events.forEach { insert(it) } }` + - Uses `DesktopFilePicker` for open dialog + +**5. Errors** (CollapsibleSection, collapsed by default) +- Recent errors from `LocalRelayMaintenance.lastError` +- Disk full warning from `LocalRelayMaintenance.diskWarning` +- Toggleable via `AnimatedVisibility` +- Error entries with timestamp + message +- "Clear errors" button + +**6. Disk Full Warning** (conditional banner) +- Pattern: `DmBroadcastBanner` style +- Colors: `MaterialTheme.colorScheme.errorContainer` +- Actions: "Prune Now" button, "Clear Cache" button + +#### Navigation Integration +- Add `LocalRelaySettings` to `DesktopScreen` sealed class +- Route from existing Settings screen (add a "Local Relay" row that navigates to it) +- Or add as tab in `RelayDashboardScreen` alongside Monitor/Configure + +--- + +### Phase 5: Offline Mode +**Goal**: Show offline indicator when no remote relays connected + +#### File to Create + +**`desktopApp/.../ui/components/OfflineBanner.kt`** + +```kotlin +@Composable +fun OfflineBanner( + connectedRelayCount: Int, + hasLocalData: Boolean, +) { + AnimatedVisibility( + visible = connectedRelayCount == 0, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = if (hasLocalData) + MaterialTheme.colorScheme.surfaceContainerHigh + else + MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (hasLocalData) Icons.Default.CloudOff else Icons.Default.Warning, + contentDescription = null, + ) + Text( + text = if (hasLocalData) + "Offline — showing cached events" + else + "Offline — no cached events available", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} +``` + +**Pattern**: Follows `DmBroadcastBanner` style (AnimatedVisibility + Surface + Row with icon/text). + +#### Integration +- Place in `DeckColumnContainer` root content, above feed content +- Observe `relayManager.connectedRelays` StateFlow +- `hasLocalData = localRelayStore.eventCount() > 0` + +--- + +## File Summary + +| File | Action | Phase | +|------|--------|-------| +| `desktopApp/.../relay/LocalRelayStore.kt` | Create | 1+2 | +| `desktopApp/.../relay/LocalRelayMaintenance.kt` | Create | 3 | +| `desktopApp/.../ui/settings/LocalRelaySettingsScreen.kt` | Create | 4 | +| `desktopApp/.../ui/components/OfflineBanner.kt` | Create | 5 | +| `desktopApp/.../cache/DesktopLocalCache.kt` | Modify (write-through hook) | 1 | +| `desktopApp/.../Main.kt` | Modify (lifecycle + hydration) | 1+2 | +| `desktopApp/.../ui/deck/DeckColumnContainer.kt` | Modify (offline banner) | 5 | +| `desktopApp/.../Main.kt` (DesktopScreen) | Modify (add LocalRelaySettings) | 4 | + +## Non-Goals (Punt) +- WebSocket server for external apps (Approach B — future) +- Sync between devices +- Full-text search UI against local store +- NostrServer / IRelayClient implementation (unnecessary for Approach A) + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Slow startup hydration | Cap at 5000 events, async (don't block UI) | +| Write buffer data loss on crash | Accept — events exist on remote relays | +| DB corruption | Detect on open, delete + recreate, log to settings errors | +| Disk full | Monitor `File.usableSpace`, disable writes at <100MB, surface in UI | +| Tag indexing overhead (80% of insert) | BasicBundledInsert batches amortize transaction cost | +| Hydration before follow list available | Query kind 3 first, then use result for metadata + content filters | + +## Testing Strategy +- Unit tests for write buffer batching behavior +- Unit tests for LocalRelayStore (open/close/account-switch lifecycle) +- Integration test: write events -> close -> reopen -> hydrate -> verify cache populated +- Integration test: NIP-09 deletion -> verify event removed from store +- Manual: launch app, browse feeds, restart, verify instant load from cache +- Manual: export/import round-trip +- Manual: settings screen — prune, vacuum, clear, toggle +- Manual: disconnect network, verify offline banner + cached events display diff --git a/desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md b/desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md new file mode 100644 index 0000000000..396ecbacc4 --- /dev/null +++ b/desktopApp/plans/2026-05-11-wallet-zapping-brainstorm.md @@ -0,0 +1,329 @@ +# Desktop Wallet & Zapping Experience - Brainstorm Map + +**Date:** 2026-05-12 +**Status:** Brainstorm v2 — phase map with research findings + decisions + +--- + +## Decisions Made + +| # | Decision | Resolution | +|---|----------|------------| +| 1 | Cashu lib: build now? | Not building yet, but may do it anyway. Research first. | +| 2 | NIP-60/61 worth investment? | Worth investigating — adoption growing | +| 3 | Key storage | `java.security.KeyStore` — platform-agnostic | +| 4 | WalletViewModel extraction | Clean — no deep Android ties | +| 5 | Hot wallet opt-in/out | **Opt-in** — user chooses to setup desktop wallet OR connect existing (e.g. mobile) | +| 6 | NWC QR on desktop | Clipboard-only paste | +| 7 | Phase priority | **NWC parity first**, then layer Cashu/hot wallet | +| 8 | Mint trust | Needs deeper trade-off discussion | +| 9 | Balance limits | Soft warning at threshold, no hard cap (see research below) | + +--- + +## Current State + +### What Desktop Has Today +- Basic zap button in `NoteActions.kt` with preset amounts (21, 100, 500, 1k, 5k, 10k) +- `NwcPaymentHandler.kt` — NIP-47 payment flow (pay invoice, wait for response) +- `ZapReceiptsDialog` — top 10 zap receipts on a note +- Opens `lightning:` URI for external wallet fallback +- No wallet management UI, no zap type selection, no transaction history + +### What Android Has (Full Feature Set) +- 6 wallet screens: Dashboard, Add, Detail, Send, Receive, Transactions +- `WalletViewModel` with full NIP-47 RPC (balance, info, transactions, invoice, pay) +- `ZapCustomDialog` with PUBLIC/PRIVATE/ANONYMOUS/NONZAP type selection +- `ReusableZapButton` with progress state +- Zap splits, zap polls, zap fundraisers +- Cashu token parsing + redemption (`CashuParser`, `MeltProcessor`) +- `ZapPaymentHandler` orchestrating splits + NWC routing +- Biometric auth for sensitive operations + +### Shared Infrastructure (Already in quartz/commons) +- **NIP-57:** `LnZapEvent`, `LnZapRequestEvent`, `LnZapPrivateEvent`, private zap encryption +- **NIP-47:** `Nip47Client`, request/response events, full RPC method set +- **NIP-60:** `CashuWalletEvent`, `CashuTokenEvent`, `CashuSpendingHistoryEvent` (skeleton) +- **NIP-61:** `NutzapEvent`, `NutzapInfoEvent` (skeleton) +- **NIP-87:** `CashuMintEvent`, `FedimintEvent`, `MintRecommendationEvent` +- **LNURL:** `LightningAddressResolver` (in commons jvmAndroid) +- **ZapAction:** Shared zap invoice fetching (commons jvmAndroid) +- **Cashu parsing:** V3+V4 token parsers, `MeltProcessor` (Android only, extractable) +- **Icons:** `Zap.kt`, `ZapSplit.kt` in commons + +--- + +## Competitor Landscape + +| Client | Platform | Wallet Type | NWC | Cashu | Hot Wallet | Standout Feature | +|--------|----------|-------------|-----|-------|------------|------------------| +| **Primal** | Web+Mobile | Custodial (Strike), migrating to Spark | Yes | No | Yes | Zero-friction, 1M sat limit | +| **Damus** | iOS | External (NWC) | Yes | No | No | Clean wallet view, high-balance warning | +| **Nostura** | iOS/macOS | External (NWC) | Yes | No | No | Balance in zap sheet | +| **Vega** | Desktop | External (NWC) | Yes | No | No | Guided wizard, zap history tabs | +| **YakiHonne** | Mobile | Built-in Cashu + NWC | Yes | Yes | Yes | Zero-config via Cashu | +| **0xchat** | Mobile | Built-in Cashu | ? | Yes | Yes | Ecash-native messaging payments | +| **Coracle** | Web | External (NWC) | Yes | ? | No | WoT-focused | +| **Snort** | Web | External (NWC) | Yes | No | No | Performance-focused | + +### Key Insights +- NWC is table stakes — every client supports it +- Cashu is the frontier — YakiHonne, 0xchat leading; NIP-60/61 merged but early +- **No desktop client has a hot wallet** — differentiation opportunity +- Vega is the best desktop reference (wizard, history, keyboard shortcuts) +- Primal is **migrating from custodial (Strike) to self-custodial (Spark)** — signals industry direction +- Zero-config onboarding (Cashu) is the killer UX pattern + +--- + +## Hot Wallet Technology Options + +### Comparison Matrix + +| Approach | Sovereignty | Complexity | UX | JVM/Kotlin | Maturity | Notes | +|----------|-------------|------------|-----|------------|----------|-------| +| **NWC (external wallet)** | High (user's wallet) | Very Low | Good | Just Nostr events | High | Phase 1 — proven, zero backend | +| **Cashu (ecash)** | Low (trust mint) | Medium | Excellent (instant) | cdk-kotlin Android-only; pure Kotlin feasible | Medium | Phase 3 candidate | +| **Breez SDK Nodeless** | Medium (Liquid federation) | Medium | Good (no channels) | Kotlin bindings exist | Medium-High | Self-custodial LN alternative | +| **LDK Node** | High (self-custody) | High | Fair (channel mgmt) | `ldk-node-jvm` on Maven | Medium | Full LN node as library | +| **lightning-kmp (ACINQ)** | High (self-custody) | Very High | Fair | Native KMP | High (Phoenix) | Best KMP fit, not designed for embedding | +| **phoenixd (sidecar)** | High (self-custody) | Medium | Good (auto liquidity) | HTTP API | High | Separate daemon | +| **Spark (Lightspark)** | High (self-custody) | Low | Excellent | **No JVM SDK yet** | Low (beta) | Watch — Primal + WoS adopting | +| **Custodial API (Strike)** | None | Low | Excellent | HTTP API | High | Requires business agreement + KYC | +| **LNbits** | Medium (your server) | Medium | Good | HTTP API | High | Requires separate server | + +### Recommendation Path +1. **Now:** NWC parity (Phase 1+2) — let users bring their own wallet +2. **Next:** Cashu hot wallet (Phase 3+4) — zero-config spending wallet +3. **Watch:** Spark Kotlin SDK — when it ships, could be the best self-custodial option +4. **Consider:** Breez SDK Nodeless as a self-custodial alternative to Cashu + +--- + +## Cashu Library Situation + +### Existing Kotlin/JVM Options + +| Library | Status | Desktop JVM? | Notes | +|---------|--------|-------------|-------| +| **cdk-kotlin** (cashubtc) | Active, v0.16.0 | **No** — Android AAR only | UniFFI wrapping Rust CDK; ARM/x86 Android ABIs only | +| **cashu-client** (thunderbiscuit) | Abandoned (2y stale) | KMP intended | Never completed | +| **cashu-bdhke-kmp** (gandlafbtc) | Dead (3y stale) | KMP | BDHKE only, usable as reference | + +### Options to Get Cashu on JVM Desktop + +| Option | Effort | Risk | Notes | +|--------|--------|------|-------| +| **A. Fork cdk-kotlin, add JVM targets** | 2-3 weeks | Medium | Build CDK Rust for desktop targets + swap AAR JNA for standard JNA | +| **B. Pure Kotlin implementation** | 4-6 weeks | Low | BDHKE ~400 lines using existing secp256k1-kmp; full mint client in Kotlin | +| **C. Hybrid (BDHKE in Kotlin + HTTP)** | 3-4 weeks | Medium | Pragmatic middle ground | + +### What Amethyst Already Has (reusable for any option) +- NIP-60/61/87 event types in quartz (commonMain, KMP-ready) +- Cashu V3+V4 token parsing (Android, extractable to commons) +- `MeltProcessor` — HTTP calls to `/melt` and `/checkfees` (extractable) +- `secp256k1-kmp` — curve operations for BDHKE foundation +- NIP-44 encryption for wallet content + +### What's Missing (regardless of library choice) +- BDHKE (blind signatures): hash-to-curve, blinding, unblinding +- Full mint API client (mint, swap, check state) +- P2PK token locking (NUT-11, needed for NIP-61 nutzaps) +- Proof management (coin selection, consolidation) +- Deterministic secret derivation (NUT-13) + +--- + +## Balance Limits Research + +| App | Type | Hard Limit | Warning | Pattern | +|-----|------|-----------|---------|---------| +| **Primal** | Custodial | 1M sats | Yes | Server-enforced, "use hardware wallet for more" | +| **Damus** | NWC | None | Dismissable high-balance reminder | Soft warning | +| **Cashu.me** | Cashu browser | None | "Small spending amounts only" | Onboarding copy | +| **Minibits** | Cashu mobile | None | Beta warning | General disclaimer | +| **Phoenix** | Self-custodial LN | None | None | No artificial limits | +| **WoS** | Custodial | 5 BTC | Implied | Server-enforced | + +### Our Approach (for Cashu hot wallet) +- **No hard cap** — self-custodial ecash, user's choice +- **Soft dismissable warning** at configurable threshold (default ~500K sats) +- **"Spending wallet" framing** in onboarding — clear this isn't savings +- **"Move to cold storage" CTA** when warning triggers +- **User-configurable threshold** — let power users set their own comfort level + +--- + +## NIP Infrastructure + +| NIP | Purpose | Status | Amethyst Support | +|-----|---------|--------|------------------| +| **47** | Nostr Wallet Connect | Merged, mature | Full in quartz, partial desktop UI | +| **57** | Lightning Zaps | Merged, mature | Full in quartz + Android, basic desktop | +| **60** | Cashu Wallet (relay-stored) | Merged (draft) | Event types in quartz, parsing in Android | +| **61** | Nutzaps (ecash zaps) | Merged | Event types in quartz only | +| **87** | Mint Discoverability | Merged (draft) | Event types in quartz only | + +--- + +## Phase Map + +### Phase 1: NWC Wallet Parity (Foundation) -- START HERE +**Goal:** Desktop matches Android's NWC wallet experience +**Priority:** Highest — this is the foundation everything else builds on + +| Work Item | Source | Action | +|-----------|--------|--------| +| Extract `WalletViewModel` | Android `WalletViewModel` | Move to `commons/commonMain/viewmodels/` | +| Wallet connection setup | Android `AddWalletScreen` | Desktop layout: paste `nostr+walletconnect://` URI | +| Wallet dashboard | Android `WalletScreen` | Desktop sidebar panel with balance + quick actions | +| Balance display | Nostura/Vega pattern | Persistent balance in sidebar | +| Send screen | Android `WalletSendScreen` | Desktop layout with paste-friendly invoice input | +| Receive screen | Android `WalletReceiveScreen` | Desktop layout with QR code + copy button | +| Transaction history | Android `WalletTransactionsScreen` | Desktop layout with search/filter/tabs | +| Multi-wallet switcher | Android `NwcSignerState` | Dropdown in wallet panel header | +| Extract NWC payment logic | Android `ZapPaymentHandler` | Shared NWC routing to commons (minus Android intents) | + +**Layout decision needed:** Sidebar wallet panel vs deck column vs both? + +--- + +### Phase 2: Zapping UX Upgrade +**Goal:** Rich zapping with desktop-first interactions +**Dependency:** Phase 1 wallet connection + +| Work Item | Source | Action | +|-----------|--------|--------| +| Zap type selection | Android `ZapCustomDialog` | Add PUBLIC/PRIVATE/ANONYMOUS toggle | +| Zap splits display | Android `DisplayZapSplits` | Extract to commons | +| Configurable presets | Account settings | User-defined amounts (not hardcoded) | +| Zap progress feedback | Android `ReusableZapButton` | Extract progress component | +| One-click zap | New | Single click = default amount; right-click = dialog | +| Keyboard shortcut zap | New (no client does this!) | `Z` = zap focused note, `Shift+Z` = custom amount | +| Zap animations | New | Subtle lightning flash on success | +| Zap receipts panel | Vega pattern | Sent/received tabs with note previews | +| Zap polls | Android `ZapPollNote` | Extract + desktop layout | +| Zap fundraisers | Android zapraiser | Extract + desktop layout | + +--- + +### Phase 3: Cashu Hot Wallet +**Goal:** Built-in ecash spending wallet — opt-in, zero-config zapping once funded +**Dependency:** Cashu crypto library (build or fork) + +| Work Item | Source | Action | +|-----------|--------|--------| +| Cashu crypto (BDHKE) | Pure Kotlin or fork cdk-kotlin | Core blind signature operations | +| Mint HTTP client | Cashu NUT spec | mint, melt, swap, check, keysets | +| NIP-60 wallet manager | Quartz skeleton | Full read/write/encrypt of wallet state on relays | +| Token lifecycle | NIP-60 | Create, spend, delete kind 7375 events | +| Extract `CashuParser` | Android `service/cashu/` | Move to commons | +| Extract `MeltProcessor` | Android `service/cashu/` | Move to commons | +| P2PK token locking | NUT-11 | Lock tokens to pubkey (needed for Phase 4) | +| Wallet key derivation | NIP-60 | Dedicated key per wallet via `java.security.KeyStore` | +| Deposit flow | Cashu mint API | LN invoice → pay → receive proofs | +| Withdraw flow | Cashu mint API | Send proofs → receive LN payment | +| Balance display | Local | Sum unspent proofs across mints | +| Wallet setup wizard | New | Opt-in: "Create spending wallet" or "Connect existing wallet" | +| Soft balance warning | Damus pattern | Dismissable warning at threshold (default 500K sats) | +| "Spending wallet" framing | Primal/Cashu.me | Clear onboarding copy | + +--- + +### Phase 4: Nutzaps (Ecash Zaps) +**Goal:** Trustless zapping via Cashu tokens (NIP-61) +**Dependency:** Phase 3 Cashu wallet + P2PK + +| Work Item | Source | Action | +|-----------|--------|--------| +| Kind 10019 publish | NIP-61 | Publish trusted mints + P2PK pubkey | +| Kind 10019 parse | NIP-61 | Read recipient's nutzap preferences | +| Kind 9321 create | NIP-61 | P2PK-lock tokens to recipient | +| Kind 9321 redeem | NIP-61 | Detect incoming nutzaps, swap into wallet | +| Nutzap display | New | Show alongside LN zaps in UI | +| Auto-redeem | New | Background coroutine to claim incoming nutzaps | +| Mint matching | NIP-61 | Find common mint between sender + recipient | +| Smart routing | New | Nutzap if possible, LN zap fallback | +| Fallback UX | New | "No common mint — send LN zap instead?" | + +--- + +### Phase 5: Advanced Wallet Features +**Goal:** Power-user and sovereignty features + +| Work Item | Description | Priority | +|-----------|-------------|----------| +| **NIP-87 mint discovery** | Browse mints recommended by follows | High | +| **Multi-mint management** | Add/remove mints, per-mint balances | High | +| **Smart payment routing** | Cashu > NWC > external, configurable | High | +| **Proof management** | Swap, consolidate, check validity | Medium | +| **Auto-swap** | Background consolidation of small proofs | Medium | +| **Wallet backup export** | Encrypted state for offline backup | Medium | +| **Budget controls** | Daily/weekly spend limits, per-zap max | Medium | +| **Zap scheduler** | Recurring zaps to favorite creators | Low | +| **Paywall support** | Pay-to-unlock via Cashu or LN | Low | +| **P2P ecash sends** | Send ecash directly to npub | Low | +| **Fedimint support** | NIP-87 kind 38173 | Low | + +### Phase 5b: Watch List (Not Building Yet) +| Technology | When to Revisit | Why | +|------------|----------------|-----| +| **Spark (Lightspark)** | When Kotlin/JVM SDK ships | Self-custodial, no channels, LN-compatible | +| **Breez SDK Nodeless** | If users want self-custodial LN | Kotlin bindings exist, Liquid-based | +| **lightning-kmp** | If ACINQ opens it for embedding | Native KMP, best language fit | + +--- + +## Extraction Inventory (Android -> Commons) + +| Component | Android Location | Extractable? | Phase | +|-----------|-----------------|-------------|-------| +| `WalletViewModel` | `amethyst/ui/screen/loggedIn/wallet/` | Yes (clean) | 1 | +| `ZapPaymentHandler` (NWC part) | `amethyst/service/` | Yes | 1 | +| `NwcSignerState` | `amethyst/model/nip47WalletConnect/` | Partially | 1 | +| `ZapCustomDialog` (state logic) | `amethyst/ui/note/` | Yes | 2 | +| `DisplayZapSplits` | `amethyst/ui/note/creators/zapsplits/` | Yes | 2 | +| `ReusableZapButton` (progress) | `amethyst/ui/components/` | Yes | 2 | +| `CashuParser` (V3+V4) | `amethyst/service/cashu/` | Yes | 3 | +| `CashuToken` / `Proof` | `amethyst/service/cashu/` | Yes | 3 | +| `MeltProcessor` | `amethyst/service/cashu/` | Yes | 3 | + +--- + +## Differentiation Opportunities + +1. **First desktop Nostr client with a hot wallet** — nobody does this +2. **Keyboard-driven zapping** — `Z` to zap, no client has this +3. **Opt-in wallet choice** — create desktop wallet OR connect mobile wallet +4. **Smart payment routing** — Cashu when possible, NWC fallback +5. **Zap history as deck column** — persistent visibility +6. **Drag-and-drop invoice payment** — drop BOLT11/cashu token +7. **Multi-mint visualization** — balance distribution across mints + +--- + +## Brainstorm Sessions Needed + +| Session | Phase | Key Questions | +|---------|-------|---------------| +| **Wallet panel layout** | 1 | Sidebar panel vs deck column? Persistent balance placement? | +| **NWC extraction** | 1 | WalletViewModel extraction plan, what stays Android-specific? | +| **Zap UX design** | 2 | Keyboard shortcuts, animation style, one-click vs dialog | +| **Cashu library strategy** | 3 | Fork cdk-kotlin vs pure Kotlin BDHKE? secp256k1-kmp capabilities? | +| **Mint trust model** | 3 | Curated list vs NIP-87 social discovery vs user-only? | +| **Nutzap routing** | 4 | Unified zap button with smart routing vs separate buttons? | + +--- + +## Unanswered Questions + +1. Does `secp256k1-kmp` (already in quartz) expose the low-level point arithmetic needed for BDHKE, or only sign/verify? +2. What is Vitor's appetite for adding native/Rust build deps to CI? (affects cdk-kotlin fork option) +3. Mint trust for new users with no social graph — curated default list? Who curates? +4. Cashu adoption curve — are enough people publishing kind 10019 (nutzap info) to make Phase 4 worthwhile near-term? +5. Should wallet panel be a permanent sidebar section or a toggleable deck column? +6. How does the "connect existing wallet" flow work cross-device? NWC URI shared how? +7. Will Spark ship a Kotlin/JVM SDK in 2026? Timeline unknown. +8. Is `cashu-bdhke-kmp` (3y stale) usable as reference code for pure Kotlin BDHKE? +9. Breez SDK Nodeless fees — are submarine swap costs acceptable for a spending wallet? +10. Should we contribute desktop JVM targets back to cdk-kotlin upstream rather than maintaining a fork? diff --git a/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md b/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md new file mode 100644 index 0000000000..5b479a72d0 --- /dev/null +++ b/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md @@ -0,0 +1,437 @@ +--- +title: "feat: Desktop Wallet & Zapping Test Coverage" +type: feat +status: active +date: 2026-05-12 +origin: desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md +--- + +# Desktop Wallet & Zapping Test Coverage + +## Enhancement Summary (Deepened 2026-05-12) + +**Key implementation insights from deepening research:** + +1. **mockk callback capture**: Use `slot<(Event, NormalizedRelayUrl) -> Unit>()` + `capture(slot)` to intercept relay subscription callbacks and feed simulated wallet responses +2. **GlobalScope.launch problem**: NwcPaymentHandler uses `GlobalScope.launch(Dispatchers.IO)` inside callbacks — tests need real time via `withTimeout(5.seconds)` to await (or refactor to inject scope) +3. **LnZapPaymentResponseEvent.createResponse()** exists — build real encrypted test responses with deterministic keys, no need to mock encryption +4. **runTest auto-advances virtual time** — timeout tests resolve instantly without real delays +5. **Codebase prefers anonymous objects** over mockk for complex interfaces (see RelayConnectionManagerTest) + +## Overview + +Add comprehensive test coverage for the desktop wallet column and enhanced zapping UX. +Quartz already has 160+ NIP-47 protocol tests (request/response serialization, URI parsing, +Alby interop). This plan covers the **desktop-specific** layer: NwcPaymentHandler RPC +methods, wallet column state management, and zap dialog logic. + +## Existing Coverage (Already Done) + +| Layer | Module | Tests | Status | +|-------|--------|-------|--------| +| NIP-47 URI parsing | quartz | `Nip47WalletConnectTest.kt` | Complete | +| NWC request serialization | quartz | `RequestTest.kt` (50+ cases) | Complete | +| NWC response parsing | quartz | `ResponseTest.kt` (50+ cases) | Complete | +| NWC event encryption | quartz | `LnZapPaymentRequestEventTest.kt` | Complete | +| Alby interop | quartz | `AlbyInteropTest.kt` (60+ cases) | Complete | +| Account management | desktopApp | `AccountManager*Test.kt` (5 files) | Complete | +| Cache pipeline | desktopApp | `DesktopCachePipelineTest.kt` | Complete | + +## New Coverage Needed + +| Component | Location | Test Type | Priority | +|-----------|----------|-----------|----------| +| `NwcPaymentHandler` | desktopApp | Unit (mockk) | P0 | +| Wallet column state | desktopApp | State management | P1 | +| Zap dialog logic | desktopApp | Unit | P1 | +| NWC RPC round-trip | desktopApp | Integration | P2 | + +## Test Framework & Patterns + +From codebase research: + +```kotlin +// Framework: kotlin.test + mockk + kotlinx-coroutines-test +import kotlin.test.* +import io.mockk.* +import kotlinx.coroutines.test.runTest + +// Pattern: relaxed mocks for infrastructure +val relayManager = mockk(relaxed = true) + +// Pattern: deterministic keys for crypto +val keyPair = KeyPair() +val signer = NostrSignerInternal(keyPair) + +// Pattern: StateFlow assertions via .value +assertEquals(expected, stateFlow.value) + +// Pattern: BeforeTest/AfterTest lifecycle +@BeforeTest fun setup() { ... } +@AfterTest fun teardown() { ... } +``` + +## Phase 1: NwcPaymentHandler Unit Tests (P0) + +**File:** `desktopApp/src/jvmTest/kotlin/.../nwc/NwcPaymentHandlerTest.kt` + +Tests the 3 NWC RPC methods + error/timeout handling. Uses mockk to +mock `DesktopRelayConnectionManager` and `DesktopLocalCache`, then +simulates wallet responses via the `onEvent` callback. + +### Test Cases + +#### payInvoice + +| # | Test | Setup | Expected | +|---|------|-------|----------| +| 1 | `payInvoice returns Success on valid response` | Mock relay, simulate PayInvoiceSuccessResponse | `PaymentResult.Success(preimage)` | +| 2 | `payInvoice returns Error on wallet error` | Simulate PayInvoiceErrorResponse | `PaymentResult.Error(message)` | +| 3 | `payInvoice returns Timeout when no response` | No simulated response, short timeout | `PaymentResult.Timeout` | +| 4 | `payInvoice returns Error when no secret` | nwcConnection with null secret | `PaymentResult.Error("no secret")` | +| 5 | `payInvoice publishes to correct relay` | Capture publishToRelay args | Correct relay URI + valid event | +| 6 | `payInvoice subscribes with correct filter` | Capture subscribeOnRelay args | Filter has kind 23195, author = wallet pubkey | +| 7 | `payInvoice tracks payment in zapped note` | Provide zappedNote mock | `zappedNote.addZapPayment()` called | +| 8 | `payInvoice cleans up subscription on cancel` | Cancel coroutine mid-flight | `closeSubscription()` called | + +#### getBalance + +| # | Test | Setup | Expected | +|---|------|-------|----------| +| 9 | `getBalance returns Success with msats` | Simulate GetBalanceSuccessResponse(balance=50000) | `BalanceResult.Success(50000)` | +| 10 | `getBalance returns Error on wallet error` | Simulate NwcErrorResponse | `BalanceResult.Error(message)` | +| 11 | `getBalance returns Timeout` | No response, short timeout | `BalanceResult.Timeout` | +| 12 | `getBalance returns Error when no secret` | null secret | `BalanceResult.Error` | +| 13 | `getBalance uses Nip47Client.fromNip47URI` | Verify request event is kind 23194 | Correct NWC request structure | + +#### makeInvoice + +| # | Test | Setup | Expected | +|---|------|-------|----------| +| 14 | `makeInvoice returns invoice on success` | Simulate MakeInvoiceSuccessResponse | `InvoiceResult.Success(invoice, hash)` | +| 15 | `makeInvoice returns Error when no invoice in response` | Simulate success with null invoice | `InvoiceResult.Error("no invoice")` | +| 16 | `makeInvoice returns Error on wallet error` | Simulate NwcErrorResponse | `InvoiceResult.Error(message)` | +| 17 | `makeInvoice returns Timeout` | No response | `InvoiceResult.Timeout` | +| 18 | `makeInvoice sends correct amount in msats` | Capture request, verify amount field | Amount matches input | + +### Implementation Pattern + +```kotlin +class NwcPaymentHandlerTest { + private lateinit var relayManager: DesktopRelayConnectionManager + private lateinit var localCache: DesktopLocalCache + private lateinit var handler: NwcPaymentHandler + private lateinit var nwcConnection: Nip47WalletConnect.Nip47URINorm + + // Capture the onEvent callback from subscribeOnRelay so we can + // feed simulated wallet responses back into the handler. + private var capturedOnEvent: ((Event, NormalizedRelayUrl) -> Unit)? = null + + @BeforeTest + fun setup() { + relayManager = mockk(relaxed = true) + localCache = mockk(relaxed = true) + handler = NwcPaymentHandler(relayManager, localCache) + + // Build a deterministic NWC connection + val walletKeyPair = KeyPair() + val clientKeyPair = KeyPair() + nwcConnection = Nip47WalletConnect.Nip47URINorm( + pubKeyHex = walletKeyPair.pubKey.toHexKey(), + relayUri = NormalizedRelayUrl("wss://relay.test/"), + secret = clientKeyPair.privKey!!.toHexKey(), + ) + + // Capture the onEvent callback + every { + relayManager.subscribeOnRelay( + relay = any(), + subId = any(), + filters = any(), + onEvent = capture(capturedOnEvent), + ) + } answers { /* store callback */ } + + // Cache verification always passes + coEvery { localCache.justVerify(any()) } returns true + } + + @Test + fun `payInvoice returns Success on valid response`() = runTest { + // Simulate wallet response in a launched coroutine + // after handler subscribes + launch { + delay(50) // Let handler subscribe first + val responseEvent = buildPayInvoiceResponse( + requestId = /* captured from subscribe filter */, + preimage = "abc123", + walletSigner = walletSigner, + clientPubKey = clientKeyPair.pubKey.toHexKey(), + ) + capturedOnEvent?.invoke(responseEvent, nwcConnection.relayUri) + } + + val result = handler.payInvoice( + bolt11 = "lnbc50n1...", + nwcConnection = nwcConnection, + ) + + assertIs(result) + assertEquals("abc123", result.preimage) + } +} +``` + +**Challenge:** The `subscribeOnRelay` callback runs asynchronously. Tests +must capture the `onEvent` lambda and invoke it with a simulated +`LnZapPaymentResponseEvent`. This requires building encrypted response +events using the wallet's signer — similar to +`quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt`. + +**Alternative (simpler):** If capturing the relay callback is too complex, +extract the response processing logic into a testable pure function and +test that directly, then integration-test the full flow separately. + +--- + +## Phase 2: Wallet Column State Tests (P1) + +**File:** `desktopApp/src/jvmTest/kotlin/.../ui/wallet/WalletColumnStateTest.kt` + +Tests the state management logic of WalletColumnScreen without Compose UI. +Extract state logic into a testable class if needed. + +### Test Cases + +| # | Test | Expected | +|---|------|----------| +| 19 | `initial state is HOME with no balance` | currentScreen=HOME, balanceSats=null | +| 20 | `navigating to CONNECT and back preserves state` | Screen transitions correctly | +| 21 | `connecting with valid URI transitions to HOME` | After setNwcConnection, screen=HOME | +| 22 | `connecting with invalid URI shows error` | connectionError is set | +| 23 | `disconnect clears connection` | nwcConnection becomes null | +| 24 | `balance updates after refresh` | balanceSats reflects RPC result | +| 25 | `send success clears invoice field` | sendInvoice="" after success | +| 26 | `send error shows error message` | sendResult contains error | +| 27 | `receive generates invoice` | generatedInvoice is set | +| 28 | `screen navigation: HOME->SEND->HOME` | Back button returns to HOME | + +### Implementation Approach + +Currently the wallet column state is all `var` inside the composable. +To test without Compose, either: + +**Option A: Extract state holder class** +```kotlin +class WalletColumnState { + var currentScreen by mutableStateOf(WalletScreen.HOME) + var balanceSats by mutableStateOf(null) + var isLoadingBalance by mutableStateOf(false) + // ... etc + + fun navigateToConnect() { currentScreen = WalletScreen.CONNECT } + fun navigateBack() { currentScreen = WalletScreen.HOME } +} +``` + +**Option B: Test via AccountManager integration** + +Test that `AccountManager.setNwcConnection()` and `clearNwcConnection()` +work correctly (these are already partially covered by existing +AccountManager tests — verify and extend). + +**Recommendation:** Option A (extract state holder) gives the cleanest +test surface. Minimal refactor — move state vars into a class, test +the class directly. + +--- + +## Phase 3: Zap Dialog Logic Tests (P1) + +**File:** `desktopApp/src/jvmTest/kotlin/.../ui/ZapDialogLogicTest.kt` + +Tests the zap amount/type selection logic without Compose rendering. + +### Test Cases + +| # | Test | Expected | +|---|------|----------| +| 29 | `default amount is first preset` | selectedAmount = 21 | +| 30 | `selecting preset updates amount` | selectedAmount = 500 after click | +| 31 | `custom amount mode enables text input` | useCustom = true | +| 32 | `custom amount parses to long` | effectiveAmount = 1337 | +| 33 | `invalid custom amount results in 0` | effectiveAmount = 0 for "abc" | +| 34 | `zap type defaults to PUBLIC` | selectedType = ZapType.PUBLIC | +| 35 | `zap type changes to PRIVATE` | selectedType = ZapType.PRIVATE | +| 36 | `zap type changes to ANONYMOUS` | selectedType = ZapType.ANONYMOUS | +| 37 | `empty message is valid` | message = "" is accepted | +| 38 | `confirm disabled when amount is 0` | enabled = false when custom="" | +| 39 | `DEFAULT_ZAP_AMOUNTS has expected values` | [21, 100, 500, 1000, 5000, 10000] | +| 40 | `formatSats formats thousands` | formatSats(5000) = "5k" | +| 41 | `formatSats keeps small numbers` | formatSats(100) = "100" | + +### Implementation + +```kotlin +class ZapDialogLogicTest { + @Test + fun `formatSats formats thousands with k suffix`() { + assertEquals("5k", formatSats(5000)) + assertEquals("10k", formatSats(10000)) + assertEquals("1k", formatSats(1000)) + } + + @Test + fun `formatSats preserves small amounts`() { + assertEquals("21", formatSats(21)) + assertEquals("100", formatSats(100)) + assertEquals("500", formatSats(500)) + } + + @Test + fun `DEFAULT_ZAP_AMOUNTS contains expected presets`() { + assertEquals(listOf(21L, 100L, 500L, 1000L, 5000L, 10000L), DEFAULT_ZAP_AMOUNTS) + } + + @Test + fun `ZapType has correct labels`() { + assertEquals("Public", ZapType.PUBLIC.label) + assertEquals("Private", ZapType.PRIVATE.label) + assertEquals("Anonymous", ZapType.ANONYMOUS.label) + } +} +``` + +**Note:** `formatSats` and `DEFAULT_ZAP_AMOUNTS` are currently `private` in +`NoteActions.kt`. Either make them `internal` (for test access) or extract +to a utility object. + +--- + +## Phase 4: NWC RPC Integration Tests (P2) + +**File:** `desktopApp/src/jvmTest/kotlin/.../nwc/NwcRpcIntegrationTest.kt` + +Full round-trip test: create NWC request event -> encrypt -> decrypt -> +verify request content -> build response -> encrypt -> decrypt -> verify. +Uses real crypto (NostrSignerInternal) with no mocking. + +### Test Cases + +| # | Test | Description | +|---|------|-------------| +| 42 | `pay_invoice round-trip` | Build request, verify BOLT11 in decrypted content | +| 43 | `get_balance round-trip` | Build request, create balance response, verify amount | +| 44 | `make_invoice round-trip` | Build request with amount, verify in decrypted content | +| 45 | `error response round-trip` | Build error response, verify error code and message | +| 46 | `NWC URI -> Nip47Client -> request event` | Full client pipeline test | + +### Implementation Pattern + +```kotlin +class NwcRpcIntegrationTest { + private val clientKeyPair = KeyPair() + private val walletKeyPair = KeyPair() + private val clientSigner = NostrSignerInternal(clientKeyPair) + private val walletSigner = NostrSignerInternal(walletKeyPair) + + @Test + fun `get_balance full round-trip`() = runTest { + // 1. Client builds request + val client = Nip47Client( + walletPubKeyHex = walletKeyPair.pubKey.toHexKey(), + relayUrl = NormalizedRelayUrl("wss://relay.test/"), + signer = clientSigner, + ) + val requestEvent = client.getBalance() + + // 2. Verify request is properly encrypted + assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + assertTrue(requestEvent.content.isNotBlank()) + + // 3. Wallet decrypts and verifies method + val decryptedRequest = requestEvent.decryptRequest(walletSigner) + assertIs(decryptedRequest) + + // 4. Wallet builds encrypted response + val responseEvent = LnZapPaymentResponseEvent.create( + request = requestEvent, + response = GetBalanceSuccessResponse( + result = GetBalanceSuccessResponse.GetBalanceResult(balance = 125000), + ), + signer = walletSigner, + ) + + // 5. Client decrypts response + val decryptedResponse = client.parseResponse(responseEvent) + assertIs(decryptedResponse) + assertEquals(125000, decryptedResponse.result?.balance) + } +} +``` + +--- + +## File Summary + +### New Test Files (4) + +``` +desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ + nwc/ + NwcPaymentHandlerTest.kt (~300 lines, 18 test cases) + NwcRpcIntegrationTest.kt (~150 lines, 5 test cases) + ui/ + wallet/WalletColumnStateTest.kt (~200 lines, 10 test cases) + ZapDialogLogicTest.kt (~100 lines, 13 test cases) +``` + +### Modified Files (1-2) + +``` +desktopApp/.../ui/NoteActions.kt (make formatSats + DEFAULT_ZAP_AMOUNTS internal) +desktopApp/.../ui/wallet/WalletColumnScreen.kt (optionally extract state holder) +``` + +### Total: ~46 test cases across 4 files + +## Implementation Order + +``` +Phase 1: NwcPaymentHandlerTest (P0) -- 18 tests, ~3h +Phase 3: ZapDialogLogicTest (P1) -- 13 tests, ~1h (quick, no deps) +Phase 2: WalletColumnStateTest (P1) -- 10 tests, ~2h (may need refactor) +Phase 4: NwcRpcIntegrationTest (P2) -- 5 tests, ~2h + ────────── + ~8h total, 46 tests +``` + +Phase 3 before Phase 2 because it's simpler (pure functions, no state +extraction needed). + +## Acceptance Criteria + +- [ ] All 46 tests pass: `./gradlew :desktopApp:test` +- [ ] NwcPaymentHandler: all 3 RPC methods tested (success + error + timeout) +- [ ] Zap dialog: amount selection, custom amounts, type selection, formatSats tested +- [ ] Wallet state: navigation, connect/disconnect, balance refresh tested +- [ ] NWC round-trip: request->encrypt->decrypt->response cycle tested +- [ ] No flaky tests (no real network, no timing-dependent assertions) +- [ ] spotlessCheck passes + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Mocking relay callback is complex | Medium | Extract response processing into pure function, test that directly | +| Compose state testing without Compose | Medium | Extract state holder class (Option A) | +| `formatSats` is private | Low | Change to `internal` visibility | +| Encrypted response building may need quartz test utils | Low | Use same pattern as `LnZapPaymentRequestEventTest.kt` | + +## Sources + +- Existing desktop tests: `desktopApp/src/jvmTest/kotlin/.../` (25+ files) +- NIP-47 protocol tests: `quartz/src/commonTest/.../nip47WalletConnect/` (13 files) +- Test framework: kotlin.test + mockk 1.14.9 + kotlinx-coroutines-test 1.10.2 +- Mock pattern: `AccountManagerBunkerLoginTest.kt` (relaxed mockk + coEvery) +- Crypto test pattern: `LnZapPaymentRequestEventTest.kt` (deterministic KeyPair) diff --git a/desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md b/desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md new file mode 100644 index 0000000000..e63ae5adc9 --- /dev/null +++ b/desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md @@ -0,0 +1,456 @@ +# Phase 1-2: NWC Wallet Parity + Zapping UX — Implementation Plan + +**Date:** 2026-05-12 +**Branch:** `feat/desktop-wallet-zapping` +**Status:** Plan — deepened, ready for work + +## Deepening Corrections (from code verification) + +1. **QR code**: ZXing already works on desktop (`QrCodeCanvas.kt`). No new lib needed. Step 1.7 is just reuse. +2. **Clipboard**: Both platforms have `ClipboardExt.kt` already. Step 1.3 wraps existing code in expect/actual. +3. **UserAvatar**: Already in `commons/` — transaction list can use it directly. +4. **WalletViewModel blocker**: `launchSigner` depends on `AccountViewModel.viewModelScope` + `toastManager`. Fix: inject `onSignerError: (String) -> Unit` callback instead of requiring AccountViewModel. +5. **No note focus tracking**: Desktop has no "focused note" concept. Keyboard shortcut zapping (Step 2.5) deferred to Phase 2b. One-click zap (Step 2.4) still works since note is passed explicitly to NoteActionsRow. +6. **NwcSignerState**: Zero Android deps confirmed. Can move to quartz or stay in commons. +7. **WalletViewModel constructor**: Takes NO params — initialized via `init(accountViewModel)` method. Extraction: make `init(account, scope, onError)` instead. + +--- + +## Phase 1: NWC Wallet Parity + +### Goal +Desktop gets a full wallet experience: connect NWC wallet, see balance, send/receive, view transactions — as a deck column. + +### Architecture Decision: Wallet as Deck Column +- Fits the existing pattern (Settings, Relays, Chess are all column types) +- User adds via AppDrawer or MenuBar "Add Column > Wallet" +- In-column drill-down for sub-screens (Send, Receive, Detail, Transactions) +- Sidebar wallet icon with balance badge (optional, Phase 1b) + +### Step 1.1: Extract Pure Types to Commons +**Target:** `commons/src/commonMain/kotlin/.../commons/viewmodels/wallet/` + +| Type | Source | Notes | +|------|--------|-------| +| `WalletSendState` (sealed) | WalletViewModel.kt `SendState` | Idle, Sending, Success(preimage), Error(msg) | +| `WalletReceiveState` (sealed) | WalletViewModel.kt `ReceiveState` | Idle, Creating, Created(invoice, amount), Error(msg) | +| `TransactionFilter` (enum) | WalletViewModel.kt | ALL, ZAPS, NON_ZAPS | +| `WalletInfo` (data class) | WalletViewModel.kt | walletId, name, alias, balanceSats, isDefault, isLoading, error | + +**Effort:** ~1 hour +**Files created:** `commons/src/commonMain/.../viewmodels/wallet/WalletTypes.kt` + +### Step 1.2: Extract SharedWalletViewModel to Commons +**Target:** `commons/src/commonMain/kotlin/.../commons/viewmodels/wallet/SharedWalletViewModel.kt` + +**Key refactoring:** +- Remove `ViewModel` base class → plain class +- Replace `viewModelScope` → constructor-injected `CoroutineScope` +- Keep all NIP-47 RPC logic (getBalance, getInfo, listTransactions, makeInvoice, payInvoice) +- Keep all state management (StateFlow, StateFlow, etc.) +- `Account` dependency is already platform-agnostic + +```kotlin +class SharedWalletViewModel( + val account: Account, + val scope: CoroutineScope, +) { + val walletInfoList: StateFlow> + val sendState: StateFlow + val receiveState: StateFlow + val transactions: StateFlow> + val selectedFilter: StateFlow + + fun selectWallet(id: String) { ... } + fun setDefault(id: String) { ... } + fun removeWallet(id: String) { ... } + fun renameWallet(id: String, name: String) { ... } + fun payInvoice(invoice: String) { ... } + fun makeInvoice(amountSats: Long, description: String) { ... } + fun refreshBalance() { ... } + fun loadTransactions() { ... } + fun filterTransactions(filter: TransactionFilter) { ... } +} +``` + +**Android wrapper:** +```kotlin +// amethyst/ +class WalletViewModel(account: Account) : ViewModel() { + val shared = SharedWalletViewModel(account, viewModelScope) + // Delegate all state/methods to shared +} +``` + +**Desktop usage:** +```kotlin +// desktopApp/ +val scope = rememberCoroutineScope() +val walletVM = remember(account) { SharedWalletViewModel(account, scope) } +``` + +**Effort:** ~3 hours +**Files created:** `commons/.../viewmodels/wallet/SharedWalletViewModel.kt` +**Files modified:** `amethyst/.../wallet/WalletViewModel.kt` (delegate to shared) + +### Step 1.3: Platform Utilities (expect/actual) +**Target:** `commons/src/commonMain/.../commons/platform/` + +| Utility | commonMain (expect) | androidMain (actual) | jvmMain (actual) | +|---------|--------------------|--------------------|-----------------| +| `getClipboardText()` | `expect suspend fun` | `ClipboardManager` | `Toolkit.getDefaultToolkit().systemClipboard` | +| `setClipboardText(text)` | `expect fun` | `ClipboardManager` | `StringSelection` + `Toolkit` | + +**Effort:** ~1 hour +**Files created:** 3 files (expect + 2 actual) + +### Step 1.4: Add DeckColumnType.Wallet +**Files to modify:** + +| File | Change | +|------|--------| +| `DeckColumnType.kt` | Add `object Wallet : DeckColumnType()` + title/typeKey | +| `AppDrawer.kt` | Add to `LAUNCHABLE_SCREENS`, category = `IDENTITY` | +| `DeckColumnContainer.kt` | Add case in `RootContent()` → render `WalletColumnScreen()` | +| `Main.kt` | Add to MenuBar "Add Column..." menu | + +**Effort:** ~1 hour + +### Step 1.5: Desktop Wallet Column Screen +**Target:** `desktopApp/src/jvmMain/.../desktop/ui/wallet/` + +**Sub-screens (in-column navigation via navStack):** + +#### 1.5a: WalletHomeScreen (default view) +``` +┌─────────────────────────┐ +│ Wallet [+ Add] │ +├─────────────────────────┤ +│ ┌─────────────────────┐ │ +│ │ Alby Hub ★ def │ │ +│ │ 125,432 sats │ │ +│ │ [Send] [Receive] │ │ +│ └─────────────────────┘ │ +│ ┌─────────────────────┐ │ +│ │ Phoenix ☆ │ │ +│ │ 50,000 sats │ │ +│ │ [Send] [Receive] │ │ +│ └─────────────────────┘ │ +│ │ +│ Recent Transactions │ +│ ⚡ Sent 1,000 sats 2m │ +│ ⚡ Recv 5,000 sats 15m │ +│ ⚡ Zap 500 sats 1h │ +└─────────────────────────┘ +``` + +#### 1.5b: AddWalletScreen +``` +┌─────────────────────────┐ +│ ← Connect Wallet │ +├─────────────────────────┤ +│ Paste NWC URI: │ +│ ┌─────────────────────┐ │ +│ │ nostr+walletconnect… │ │ +│ └─────────────────────┘ │ +│ [Paste from clipboard] │ +│ │ +│ Wallet Name: │ +│ ┌─────────────────────┐ │ +│ │ My Alby Hub │ │ +│ └─────────────────────┘ │ +│ │ +│ [Connect] │ +│ │ +│ ──────────────────── │ +│ Supported wallets: │ +│ Alby Hub, Phoenix, │ +│ Coinos, LNbits, Zeus │ +└─────────────────────────┘ +``` + +#### 1.5c: WalletSendScreen +``` +┌─────────────────────────┐ +│ ← Send │ +├─────────────────────────┤ +│ Invoice or LN Address: │ +│ ┌─────────────────────┐ │ +│ │ lnbc... │ │ +│ └─────────────────────┘ │ +│ [Paste] │ +│ │ +│ Amount: 1,000 sats │ +│ │ +│ [Pay Invoice] │ +│ │ +│ Status: Sending... │ +│ ████████░░ 80% │ +└─────────────────────────┘ +``` + +#### 1.5d: WalletReceiveScreen +``` +┌─────────────────────────┐ +│ ← Receive │ +├─────────────────────────┤ +│ Amount (sats): │ +│ ┌─────────────────────┐ │ +│ │ 10000 │ │ +│ └─────────────────────┘ │ +│ Description: │ +│ ┌─────────────────────┐ │ +│ │ Coffee fund │ │ +│ └─────────────────────┘ │ +│ │ +│ [Create Invoice] │ +│ │ +│ ┌───────────────┐ │ +│ │ QR CODE │ │ +│ │ (invoice) │ │ +│ └───────────────┘ │ +│ lnbc10u1pj... [Copy] │ +└─────────────────────────┘ +``` + +#### 1.5e: WalletTransactionsScreen +``` +┌─────────────────────────┐ +│ ← Transactions │ +├─────────────────────────┤ +│ [All] [Zaps] [Non-Zaps] │ +├─────────────────────────┤ +│ Today │ +│ ⚡↑ 1,000 sats @alice │ +│ ⚡↓ 5,000 sats @bob │ +│ Yesterday │ +│ ⚡↑ 500 sats @carol │ +│ ... │ +└─────────────────────────┘ +``` + +**Effort:** ~6 hours (all sub-screens) +**Files created:** +- `desktopApp/.../ui/wallet/WalletColumnScreen.kt` +- `desktopApp/.../ui/wallet/WalletHomeContent.kt` +- `desktopApp/.../ui/wallet/AddWalletContent.kt` +- `desktopApp/.../ui/wallet/WalletSendContent.kt` +- `desktopApp/.../ui/wallet/WalletReceiveContent.kt` +- `desktopApp/.../ui/wallet/WalletTransactionsContent.kt` + +### Step 1.6: Migrate NWC Config from RelaySettings to Wallet Column +- Remove NWC section from `RelaySettingsScreen` +- Add "Manage in Wallet column" link if wallet column exists +- NWC connection management now lives in AddWalletScreen + +**Effort:** ~1 hour + +### Step 1.7: QR Code Generation (Desktop) +- Need QR code composable for WalletReceiveScreen +- Options: `io.github.alexzhirkevich:qrose` (KMP QR library) or ZXing +- Already used on Android? Check and reuse if possible + +**Effort:** ~2 hours (evaluate + integrate) + +--- + +## Phase 2: Zapping UX Upgrade + +### Goal +Rich zapping with zap types, configurable presets, one-click zaps, keyboard shortcuts, and progress feedback. + +### Step 2.1: Extract Zap ViewModels to Commons +**Target:** `commons/src/commonMain/.../viewmodels/zap/` + +| Component | Source | Notes | +|-----------|--------|-------| +| `ZapOptionViewModel` | ZapCustomDialog.kt:96-113 | customAmount + customMessage state | +| `UpdateZapAmountViewModel` | UpdateZapAmountViewModel.kt | zapAmounts, selectedZapType, NWC config | + +**Effort:** ~1 hour +**Files created:** `commons/.../viewmodels/zap/ZapOptionViewModel.kt`, `ZapSettingsViewModel.kt` + +### Step 2.2: Extract Zap UI Components to Commons +**Target:** `commons/src/commonMain/.../ui/components/zap/` + +| Component | Source | Platform deps | Action | +|-----------|--------|--------------|--------| +| `ZapCustomDialog` | ZapCustomDialog.kt:117-332 | None | Move as-is | +| `ZapAmountChoicePopup` | ReactionsRow.kt:1778-1897 | None | Extract to own file | +| `ZapTypeSelector` | ZapCustomDialog.kt (type chips) | None | Extract as standalone | +| `UpdateZapAmountContent` | UpdateZapAmountDialog.kt:138-649 | BiometricPrompt (skip on desktop) | Extract, make auth optional | + +**Platform-specific (expect/actual):** +| Function | commonMain | androidMain | jvmMain | +|----------|-----------|-------------|---------| +| `payInvoice(invoice)` | expect | Intent ACTION_VIEW | `Desktop.browse(URI("lightning:$invoice"))` | + +**Effort:** ~3 hours +**Files created:** ~4 files in `commons/.../ui/components/zap/` + +### Step 2.3: Upgrade Desktop ZapAmountDialog +Replace current basic `ZapAmountDialog` in `NoteActions.kt` with extracted `ZapCustomDialog`. + +**New features:** +- Zap type selection (PUBLIC/PRIVATE/ANONYMOUS) +- Configurable preset amounts from account settings +- Custom amount + message input +- Progress feedback during payment + +**Effort:** ~2 hours +**Files modified:** `desktopApp/.../ui/NoteActions.kt` + +### Step 2.4: One-Click Zap +**Behavior:** +- Single left-click on zap icon → send default amount as default zap type (no dialog) +- Right-click on zap icon → open ZapCustomDialog +- Visual feedback: brief flash/highlight on successful zap + +**Implementation:** +- Read `account.settings.syncedSettings.zaps.zapAmountChoices[0]` as default +- Read `account.settings.syncedSettings.zaps.defaultZapType` as default type +- Call `ZapAction.fetchZapInvoice()` → `NwcPaymentHandler.payInvoice()` inline +- Show success/error via snackbar + +**Effort:** ~2 hours +**Files modified:** `desktopApp/.../ui/NoteActions.kt` + +### Step 2.5: Keyboard Shortcut Zapping +**Design:** +- `Z` — zap focused/hovered note with default amount (same as one-click) +- `Shift+Z` — open ZapCustomDialog for focused/hovered note + +**Implementation:** +- Desktop already has keyboard shortcuts in MenuBar (Main.kt) +- Need "focused note" concept — track which note the mouse is hovering over or keyboard-navigated to +- Add to existing `KeyShortcut` system + +**Note:** This requires a "focused note" tracking system. If note focus doesn't exist yet, this becomes more complex. May need to defer to Phase 2b or implement basic hover-tracking first. + +**Effort:** ~3 hours (if focus system exists) / ~6 hours (if building focus tracking) +**Risk:** Medium — depends on existing focus/hover infrastructure + +### Step 2.6: Zap Progress & Animations +**Behavior:** +- During payment: zap icon pulses or shows mini spinner +- On success: brief lightning flash effect +- On error: red shake + snackbar + +**Implementation:** +- Extract `ObserveZapIcon` pattern from Android +- Use `Animatable` for pulse/flash +- Integrate with `NwcPaymentHandler` response callback + +**Effort:** ~2 hours + +### Step 2.7: Zap Settings in Wallet Column +Add "Zap Settings" section to WalletHomeScreen: +- Configure preset amounts (drag-to-reorder) +- Set default zap type +- Set default zap amount for one-click + +**Effort:** ~2 hours + +--- + +## Implementation Order + +``` +Phase 1 Phase 2 +──────── ──────── +1.1 Extract types (1h) ──→ 2.1 Extract zap VMs (1h) +1.2 SharedWalletViewModel (3h) ──→ 2.2 Extract zap UI (3h) +1.3 Platform utils (1h) 2.3 Upgrade zap dialog (2h) +1.4 DeckColumnType.Wallet (1h) 2.4 One-click zap (2h) +1.5 Desktop wallet screens (6h) 2.5 Keyboard shortcuts (3-6h) +1.6 Migrate NWC config (1h) 2.6 Zap animations (2h) +1.7 QR code generation (2h) 2.7 Zap settings (2h) +──── ──── +~15h total ~13-16h total +``` + +**Critical path:** 1.1 → 1.2 → 1.4 → 1.5 (wallet column must exist before zap settings in 2.7) +**Parallelizable:** 1.3 + 1.4 can happen alongside 1.2; 2.1 + 2.2 can happen alongside 1.5 + +--- + +## Testing Strategy + +### Phase 1 Testing +- [ ] Connect NWC wallet via clipboard paste (Alby Hub, Phoenix) +- [ ] Balance refreshes and displays correctly +- [ ] Send payment to BOLT11 invoice +- [ ] Create receive invoice + display QR +- [ ] Transaction list loads with filter tabs +- [ ] Multi-wallet: add second wallet, switch default +- [ ] Wallet column persists across app restart +- [ ] Remove wallet + re-add + +### Phase 2 Testing +- [ ] Zap dialog shows type selection (PUBLIC/PRIVATE/ANONYMOUS) +- [ ] Custom amount + message work +- [ ] One-click zap sends default amount without dialog +- [ ] Right-click opens custom zap dialog +- [ ] Keyboard Z zaps focused note +- [ ] Keyboard Shift+Z opens dialog +- [ ] Zap progress animation shows during payment +- [ ] Success/error feedback via snackbar +- [ ] Zap settings persist (amounts, type, default) + +--- + +## Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| WalletViewModel has hidden Android deps | Low | Agent analysis shows clean extraction | +| QR generation library compatibility | Low | Multiple KMP options exist | +| Keyboard shortcut needs focus tracking | Medium | Can defer to Phase 2b; one-click works without it | +| NWC wallet response timeouts | Low | Already handled in NwcPaymentHandler (30s timeout) | +| Account settings sync between platforms | Medium | Using existing AccountSettings infrastructure | + +--- + +## Files Summary + +### New Files (~15) +``` +commons/src/commonMain/.../viewmodels/wallet/WalletTypes.kt +commons/src/commonMain/.../viewmodels/wallet/SharedWalletViewModel.kt +commons/src/commonMain/.../viewmodels/zap/ZapOptionViewModel.kt +commons/src/commonMain/.../viewmodels/zap/ZapSettingsViewModel.kt +commons/src/commonMain/.../ui/components/zap/ZapCustomDialog.kt +commons/src/commonMain/.../ui/components/zap/ZapAmountChoicePopup.kt +commons/src/commonMain/.../ui/components/zap/ZapTypeSelector.kt +commons/src/commonMain/.../platform/ClipboardUtils.kt (expect) +commons/src/androidMain/.../platform/ClipboardUtils.kt (actual) +commons/src/jvmMain/.../platform/ClipboardUtils.kt (actual) +desktopApp/.../ui/wallet/WalletColumnScreen.kt +desktopApp/.../ui/wallet/WalletHomeContent.kt +desktopApp/.../ui/wallet/AddWalletContent.kt +desktopApp/.../ui/wallet/WalletSendContent.kt +desktopApp/.../ui/wallet/WalletReceiveContent.kt +desktopApp/.../ui/wallet/WalletTransactionsContent.kt +``` + +### Modified Files (~8) +``` +amethyst/.../wallet/WalletViewModel.kt (delegate to shared) +desktopApp/.../deck/DeckColumnType.kt (add Wallet) +desktopApp/.../deck/DeckColumnContainer.kt (add RootContent case) +desktopApp/.../deck/AppDrawer.kt (add to LAUNCHABLE_SCREENS) +desktopApp/.../Main.kt (MenuBar + migrate NWC) +desktopApp/.../ui/NoteActions.kt (upgrade zap dialog, one-click, keyboard) +desktopApp/.../RelaySettingsScreen (remove NWC section) +``` + +--- + +## Unanswered Questions + +1. Does desktop have a "focused/hovered note" concept for keyboard shortcuts? If not, how much work to add? +2. QR code library: is `qrose` already a dependency, or do we need to add it? What does Android use? +3. Should wallet balance show in sidebar icon (badge) or only in wallet column? +4. UserPicture/UsernameDisplay for transaction list — are these already in commons or need extraction? +5. How should wallet column handle being opened when no wallet is connected? (show AddWallet immediately?) +6. Should the wallet column auto-refresh balance on a timer, or only on user action? diff --git a/desktopApp/plans/2026-05-20-wallet-zapping-manual-testing-sheet.md b/desktopApp/plans/2026-05-20-wallet-zapping-manual-testing-sheet.md new file mode 100644 index 0000000000..83398fd5cb --- /dev/null +++ b/desktopApp/plans/2026-05-20-wallet-zapping-manual-testing-sheet.md @@ -0,0 +1,175 @@ +# Desktop Wallet & Zapping — Manual Testing Sheet + +**Date:** 2026-05-20 +**Branch:** `feat/desktop-wallet-zapping` +**Prerequisites:** NWC-compatible wallet (Alby Hub, Coinos, Phoenix, or LNbits) + +--- + +## Setup + +Before testing, get your NWC URI ready: +- **Alby Hub:** Settings > Wallet Connections > + New > copy `nostr+walletconnect://...` +- **Coinos:** Wallet > NWC > Create connection > copy URI +- **Phoenix:** Settings > Wallet Connect > copy URI + +Run the desktop app: `./gradlew :desktopApp:run` + +--- + +## A. Wallet Column — No Wallet State + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| A1 | Column appears | Add Wallet column via AppDrawer or sidebar | "No Wallet Connected" screen with wallet icon | | +| A2 | CTA text | Read the empty state | Shows NWC explanation + "Connect Wallet" button | | +| A3 | Connect button opens dialog | Click "Connect Wallet" | ConnectWalletDialog appears | | +| A4 | Cancel dialog | Open connect dialog > Cancel | Dialog closes, still on empty state | | + +## B. NWC Connection + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| B1 | Paste valid NWC URI | Paste `nostr+walletconnect://...` into field > Connect | Dialog closes, snackbar "Wallet connected!" | | +| B2 | Paste from clipboard | Copy NWC URI > click "Paste from Clipboard" | URI appears in text field | | +| B3 | Invalid URI rejected | Type `garbage` > Connect | Error: "Invalid NWC URI. Expected: nostr+walletconnect://..." | | +| B4 | Empty URI rejected | Leave field empty | Connect button is disabled (greyed out) | | +| B5 | URI with spaces/newlines | Paste URI with leading/trailing whitespace | Should still connect (or show clear error) | | +| B6 | Wallet info shown | After connecting | Shows relay URL + truncated wallet pubkey | | +| B7 | Persistence | Connect wallet > restart app | Wallet should still be connected on restart | | + +## C. Balance + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| C1 | Auto-fetch on connect | Connect wallet | Balance card shows spinner, then sats amount | | +| C2 | Balance formatting | Have >1000 sats | Shows comma-separated (e.g. "12,345 sats") | | +| C3 | Refresh button | Click "Refresh" | Spinner appears, balance updates | | +| C4 | Balance error | Disconnect internet > Refresh | Snackbar: "Balance request timed out" (after 30s) | | +| C5 | Zero balance | Use wallet with 0 sats | Shows "0 sats" (not "--" or error) | | +| C6 | Balance after send | Send payment > observe balance | Balance should NOT auto-update (need manual refresh) | | + +## D. Send Payment + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| D1 | Open send dialog | Click "Send" button | SendDialog appears with invoice field | | +| D2 | Paste invoice | Copy BOLT11 > "Paste from Clipboard" | Invoice appears in field | | +| D3 | Pay valid invoice | Paste real invoice > "Pay Invoice" | Spinner appears, then snackbar "Payment successful!", dialog closes | | +| D4 | Pay expired invoice | Paste expired BOLT11 > Pay | Snackbar with error message from wallet | | +| D5 | Pay invalid string | Type `not-an-invoice` > Pay | Error from wallet (check it doesn't crash) | | +| D6 | Cancel during send | Start paying > close dialog | Payment may still complete in background — no crash | | +| D7 | Empty invoice | Leave field empty | "Pay Invoice" button is disabled | | +| D8 | Timeout | Pay invoice while wallet is offline | Snackbar "Payment timed out" after ~60s | | +| D9 | Double-click prevention | Click "Pay Invoice" twice quickly | Button disables after first click, shows "Sending..." | | + +## E. Receive Payment + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| E1 | Open receive dialog | Click "Receive" button (outlined) | ReceiveDialog with amount + description fields | | +| E2 | Generate invoice | Enter 100 sats > "Create Invoice" | Dialog title changes to "Invoice Created", shows BOLT11 string | | +| E3 | Copy invoice | Generate invoice > "Copy Invoice" | Invoice copied to clipboard, snackbar "Invoice copied!" | | +| E4 | Amount validation | Type letters in amount field | Only digits accepted | | +| E5 | Zero amount | Enter 0 > Create Invoice | Nothing happens (button should be disabled for blank, but 0 may pass — note behavior) | | +| E6 | Large amount | Enter 1000000 sats | Invoice generated successfully (or wallet-specific limit error) | | +| E7 | Description | Enter amount + description > Create | Invoice created (verify description doesn't break anything) | | +| E8 | Timeout | Generate while wallet offline | Snackbar "Invoice request timed out" | | +| E9 | Close after generate | Generate > "Close" | Dialog closes cleanly | | +| E10 | Pay the invoice | Copy generated invoice > pay from external wallet | Payment should succeed (verify with balance refresh) | | + +## F. Disconnect Wallet + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| F1 | Disconnect | Click red "Disconnect" text | Snackbar "Wallet disconnected", returns to empty state | | +| F2 | Balance clears | Disconnect | Balance resets to null (shows empty state, not stale balance) | | +| F3 | Reconnect | Disconnect > Connect again | Full flow works, balance fetches again | | +| F4 | Persistence after disconnect | Disconnect > restart app | Should remain disconnected | | + +## G. Zapping Notes (NoteActionsRow) + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| G1 | Quick zap (left-click) | With wallet connected, left-click zap icon on a note | Spinner on icon, then ZapFeedback.Success, icon turns primary color | | +| G2 | Quick zap amount | Left-click zap | Should zap 21 sats (first preset) | | +| G3 | Custom zap (right-click) | Right-click zap icon | ZapAmountDialog opens | | +| G4 | Preset amounts | Open zap dialog | Shows chips: 21, 100, 500, 1k, 5k, 10k | | +| G5 | Select preset | Click "500" chip > Zap | Zaps 500 sats | | +| G6 | Custom amount | Click "Custom" > type 42 > Zap | Zaps 42 sats | | +| G7 | Zap types | Open dialog | Shows Public/Private/Anonymous filter chips | | +| G8 | Zap type selection | Select "Private" > note label change | Label changes to "Private message (only recipient sees)" | | +| G9 | Zap message | Type message > Zap | Zap includes message (verify in receipts) | | +| G10 | No wallet — external | Without wallet connected, left-click zap | Opens ZapAmountDialog (not quick zap) | | +| G11 | No lightning address | Zap a user with no LN address | ZapFeedback.NoLightningAddress feedback | | +| G12 | Zap counter updates | After successful zap | Zap amount on note should reflect new total | | + +**NOTE:** ZapType PRIVATE/ANONYMOUS is wired to the dialog but the TODO at line 899 says it's not yet passed to ZapAction. Verify Public works; Private/Anonymous may silently fall back to Public. + +## H. Zap Receipts Dialog + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| H1 | Open receipts | Click the zap amount text on a note that has zaps | ZapReceiptsDialog opens | | +| H2 | Receipt content | View receipts | Shows sender name, amount, message | | +| H3 | Metadata loading | Receipts from unknown users | Shows spinner while loading, then names appear | | +| H4 | Sorting | Multiple zaps | Sorted by amount descending | | +| H5 | Overflow | Note with >10 zaps | Shows top 10 + "and N more..." | | +| H6 | Empty state | Note with 0 zaps | "No zaps yet" message | | +| H7 | Close | Click "Close" | Dialog dismisses | | + +## I. Edge Cases & Error Handling + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| I1 | Network loss mid-operation | Start a payment > disconnect wifi | Timeout after configured period, no crash | | +| I2 | Wallet column + no relays | Disconnect all relays > try balance | Graceful error or timeout | | +| I3 | Multiple rapid zaps | Quick-click zap on 3 different notes fast | Each processes independently, no double-spend crash | | +| I4 | Re-zap same note | Zap a note, then zap it again | Second zap should work (stacking zaps is normal) | | +| I5 | Very long NWC URI | Paste extremely long URI | TextField handles it, no UI overflow | | +| I6 | Column resize | Resize wallet column narrower/wider | UI adapts (max 360dp content width) | | +| I7 | Snackbar stacking | Trigger multiple snackbars quickly | No crash, messages queue properly | | + +## J. Cross-Feature + +| # | Test | Steps | Expected | Pass? | +|---|------|-------|----------|-------| +| J1 | Wallet + other columns | Have Home + Wallet columns side by side | Both function, zaps from Home use connected wallet | | +| J2 | React/Repost still work | Like, repost, bookmark a note | All work independently of wallet state | | +| J3 | Copy note/event links | Use overflow menu on a note | Copies correct nostr: links to clipboard | | +| J4 | Bookmark dialog | Click bookmark icon | Public/Private dialog appears, bookmarking works | | + +--- + +## Known Limitations / TODOs + +- Private/Anonymous zap types — dialog exists but not wired to ZapAction (line 899 TODO) +- No transaction history screen yet (Phase 2) +- No keyboard shortcut for zapping (deferred to Phase 2b) +- Balance doesn't auto-update after send/receive (manual refresh required) +- Metadata fetch uses GlobalScope (line 504, 1142) — works but not ideal for structured concurrency + +## Test Wallets + +| Wallet | Best For | Notes | +|--------|----------|-------| +| **Alby Hub** | Full NWC testing | Self-hosted, full RPC support | +| **Coinos** | Quick setup | Custodial, easy NWC URI | +| **Phoenix** | Real mobile wallet | Good for realistic testing | +| **Mutiny (RIP)** | N/A | Shut down — don't use | + +## Results Summary + +| Section | Total | Pass | Fail | Skip | Notes | +|---------|-------|------|------|------|-------| +| A. No Wallet | 4 | | | | | +| B. Connection | 7 | | | | | +| C. Balance | 6 | | | | | +| D. Send | 9 | | | | | +| E. Receive | 10 | | | | | +| F. Disconnect | 4 | | | | | +| G. Zapping | 12 | | | | | +| H. Receipts | 7 | | | | | +| I. Edge Cases | 7 | | | | | +| J. Cross-Feature | 4 | | | | | +| **TOTAL** | **70** | | | | | 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/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md new file mode 100644 index 0000000000..def49f9f7e --- /dev/null +++ b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md @@ -0,0 +1,88 @@ +# Brainstorm: Send Dialog LNURL-Pay Support + +**Date:** 2026-05-23 +**Status:** Ready for planning + +## What We're Building + +Extend the desktop wallet Send dialog to accept LNURL-pay strings and lightning addresses in addition to BOLT11 invoices. The dialog auto-detects input type, resolves LNURL endpoints, and presents an amount/comment form before fetching the final BOLT11 invoice and paying via NWC. + +## Why + +Users commonly receive payment requests as lightning addresses (`user@domain`) or LNURL bech32 strings, not just raw BOLT11 invoices. The current send dialog rejects these with a confusing "Unknown chain url" error from the NWC wallet. + +## Input Types + +| Format | Example | Detection | +|--------|---------|-----------| +| BOLT11 | `lnbc210n1p4pr...` | Starts with `lnbc` (regex in `LnInvoiceUtil`) | +| LNURL | `lnurl1dp68gurn...` | Starts with `lnurl` (decode via `Lud06.toLnUrlp`) | +| Lightning address | `user@domain.com` | Contains `@`, split on `@` | + +## Dialog Flow + +### State Machine + +``` +Input -> detecting type... + | + |- BOLT11 detected -> [Pay Invoice] (current flow, no change) + | + |- LNURL/address detected -> resolving endpoint (spinner)... + | + |- Fixed amount -> show amount (read-only), optional comment -> [Pay] + |- Variable amount -> show amount field with min/max hint, optional comment -> [Pay] + |- Resolution error -> inline error (copiable) + | + [Pay] -> fetching invoice (spinner)... + | + |- Got BOLT11 -> paying via NWC (spinner)... + | |- Success -> close dialog, snackbar + | |- Error -> inline error, button resets to [Pay] + |- Fetch error -> inline error, button resets to [Pay] +``` + +### UI States + +1. **Input** — text field + paste button (current) +2. **Resolving** — spinner below input, input disabled +3. **Amount** — shows endpoint info + amount field (with min/max label) + optional comment field +4. **Paying** — button shows "Paying...", fields disabled +5. **Error** — inline red text (copiable via SelectionContainer), button resets + +### Amount Input + +- Label: `"Amount (1 - 500,000 sats)"` — populated from `minSendable`/`maxSendable` (converted from msats) +- If fixed amount (`minSendable == maxSendable`): show read-only, prepopulated +- Digits only, validate against range on submit +- Comment field: only visible if `commentAllowed > 0` from endpoint response + +## Key Decisions + +- **Auto-detect on paste/type** — no explicit "Resolve" button; detect as user types/pastes +- **Single dialog, multi-step** — no separate dialogs for LNURL vs BOLT11 +- **Reuse `LightningAddressResolver`** — already handles LNURL endpoint fetch + invoice callback +- **Comment support** — show optional comment field when endpoint allows it +- **Amount in sats** — convert msats from LNURL spec to sats for display +- **Error display** — inline, copiable, same pattern as current SendDialog + +## Reusable Code + +| Component | Location | Notes | +|-----------|----------|-------| +| `Lud06.toLnUrlp()` | quartz | Decodes LNURL bech32 to URL | +| `LnInvoiceUtil.findInvoice()` | quartz | Detects BOLT11 pattern | +| `LightningAddressResolver.assembleUrl()` | commons | `user@domain` -> endpoint URL | +| `LightningAddressResolver.fetchInvoice()` | commons | Full LNURL-pay flow (fetch endpoint, get invoice) | +| `NwcPaymentHandler.payInvoice()` | desktopApp | Pay BOLT11 via NWC | + +## Resolved Questions + +- **Input types**: BOLT11 + LNURL + lightning address (all three) +- **Amount UX**: Free text field with min/max hint from endpoint +- **Comments**: Yes, show comment field if `commentAllowed > 0` +- **Flow**: Auto-detect + resolve inline (single dialog, multi-step) + +## Open Questions + +None — all questions resolved during brainstorm. diff --git a/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-plan.md b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-plan.md new file mode 100644 index 0000000000..4a3e2c8ba2 --- /dev/null +++ b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-plan.md @@ -0,0 +1,183 @@ +--- +title: "feat: Support LNURL-pay and lightning addresses in send dialog" +type: feat +status: active +date: 2026-05-23 +origin: desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md +deepened: 2026-05-23 +--- + +# feat: Support LNURL-pay and lightning addresses in send dialog + +## Enhancement Summary + +**Deepened on:** 2026-05-23 +**Research agents:** LNURL edge cases, Compose state machine patterns + +### Key Improvements from Research +1. `LightningAddressResolver` doesn't parse `minSendable`/`maxSendable`/`commentAllowed` — must fetch and parse endpoint JSON directly in dialog +2. Use `ImportFollowListDialog` sealed class state machine pattern (proven in codebase) +3. Strip `lightning:` URI prefix from pasted input +4. `fetchInvoice()` can be called with just amount (no zap request) — reuse for final invoice fetch + +## Overview + +Extend the desktop SendDialog to accept LNURL bech32 strings and lightning addresses (`user@domain`) in addition to BOLT11 invoices. Auto-detect input type, resolve LNURL endpoints, and present an amount/comment form before fetching the final BOLT11 invoice and paying via NWC. + +## Problem + +User pastes an LNURL or lightning address into the send dialog and gets "Unknown chain url: invalid token" error because the dialog only supports BOLT11 invoices. + +## Proposed Solution + +Single dialog, multi-step flow with auto-detection (see brainstorm). + +### Input Detection + +```kotlin +fun classifyInput(input: String): PaymentInput { + val trimmed = input.trim() + .removePrefix("lightning:") // Strip lightning: URI prefix + .trim() + // 1. BOLT11: starts with lnbc + LnInvoiceUtil.findInvoice(trimmed)?.let { return PaymentInput.Bolt11(it) } + // 2. LNURL bech32: starts with lnurl + if (trimmed.lowercase().startsWith("lnurl")) { + Lud06().toLnUrlp(trimmed)?.let { return PaymentInput.LnurlPay(it) } + } + // 3. Lightning address: user@domain + if (trimmed.contains("@") && trimmed.contains(".")) { + val parts = trimmed.split("@") + if (parts.size == 2) return PaymentInput.LnurlPay("https://${parts[1]}/.well-known/lnurlp/${parts[0]}") + } + return PaymentInput.Unknown +} +``` + +### Dialog State Machine + +Follow `ImportFollowListDialog` pattern — sealed class with `LaunchedEffect` auto-transitions. + +```kotlin +sealed class SendState { + data object Idle : SendState() + data class Resolving(val url: String) : SendState() + data class NeedsAmount( + val lnAddress: String, // original input for fetchInvoice + val callback: String, + val minSats: Long, + val maxSats: Long, + val commentAllowed: Int, + ) : SendState() + data class ReadyToPay(val bolt11: String) : SendState() + data class FetchingInvoice(val lnAddress: String, val amountSats: Long, val comment: String) : SendState() + data object Paying : SendState() + data class Error(val message: String) : SendState() +} +``` + +### State Transitions via LaunchedEffect + +```kotlin +// Auto-resolve LNURL endpoint when entering Resolving state +LaunchedEffect(sendState) { + val state = sendState + if (state is SendState.Resolving) { + // Fetch LNURL-pay JSON using OkHttp directly + val json = fetchLnurlPayEndpoint(state.url) + // Parse: callback, minSendable, maxSendable, commentAllowed + // Transition to NeedsAmount or Error + } +} + +// Auto-fetch invoice when entering FetchingInvoice state +LaunchedEffect(sendState) { + val state = sendState + if (state is SendState.FetchingInvoice) { + val resolver = LightningAddressResolver(DesktopHttpClient.currentClient()) + val result = resolver.fetchInvoice( + lnAddress = state.lnAddress, + milliSats = state.amountSats * 1000, + message = state.comment, + ) + // Transition to ReadyToPay or Error + } +} +``` + +### LNURL Endpoint Parsing (new logic in dialog) + +```kotlin +// Fetch and parse LNURL-pay endpoint JSON +// Fields needed: callback, minSendable, maxSendable, commentAllowed +val lnurlp = mapper.readTree(responseBody) +val callback = lnurlp.get("callback")?.asText() +val minSendable = lnurlp.get("minSendable")?.asLong() ?: 1000 // msats +val maxSendable = lnurlp.get("maxSendable")?.asLong() ?: 100_000_000 // msats +val commentAllowed = lnurlp.get("commentAllowed")?.asInt() ?: 0 +val isFixed = minSendable == maxSendable +``` + +### UI Layout per State + +| State | Shows | +|-------|-------| +| Idle | Input field ("Payment request or lightning address"), paste button | +| Resolving | Input (disabled), spinner below | +| NeedsAmount | Input (disabled, shows address), amount field w/ "Amount (min - max sats)", comment (if allowed), Pay | +| ReadyToPay | Input (disabled), Pay button | +| FetchingInvoice | Fields disabled, spinner | +| Paying | All disabled, "Paying..." button | +| Error | Inline red copiable text, Retry button resets to appropriate prior state | + +### Edge Cases + +- **`lightning:lnbc...`** prefix: strip before classification +- **Fixed amount** (`minSendable == maxSendable`): prepopulate, make read-only +- **Amount out of range**: validate client-side before fetching invoice, show inline error +- **Endpoint timeout**: 15s timeout on LNURL fetch, show error +- **Invoice amount mismatch**: `fetchInvoice()` already validates this (line 172) +- **`commentAllowed = 0`**: hide comment field entirely +- **Invalid LNURL bech32**: `Lud06().toLnUrlp()` returns null → stays Unknown + +## Files to Modify + +| File | Change | +|------|--------| +| `WalletColumnScreen.kt` (SendDialog) | Rewrite to multi-step flow with sealed state machine | + +No new files needed. All LNURL utilities already exist in quartz/commons. + +## Reusable Code + +| Component | Location | Usage | +|-----------|----------|-------| +| `LnInvoiceUtil.findInvoice()` | quartz | Detect BOLT11 | +| `Lud06().toLnUrlp()` | quartz | Decode LNURL bech32 | +| `LightningAddressResolver.fetchInvoice()` | commons | Fetch invoice with amount (no zap request) | +| `DesktopHttpClient.currentClient()` | desktopApp | OkHttpClient | +| `NwcPaymentHandler.payInvoice()` | desktopApp | Pay BOLT11 via NWC | +| `jacksonObjectMapper()` | already imported | Parse LNURL endpoint JSON | + +## Acceptance Criteria + +- [ ] Pasting a BOLT11 invoice works as before (no regression) +- [ ] Pasting `lightning:lnbc...` works (prefix stripped) +- [ ] Pasting an LNURL bech32 string resolves and shows amount form +- [ ] Pasting a lightning address (user@domain) resolves and shows amount form +- [ ] Fixed-amount LNURL prepopulates amount (read-only) +- [ ] Variable-amount LNURL shows input with min/max hint +- [ ] Comment field appears when endpoint `commentAllowed > 0` +- [ ] Amount validated against min/max before fetching invoice +- [ ] Errors shown inline (copiable), button resets for retry +- [ ] "Paste from Clipboard" works for all input types +- [ ] Label changed from "BOLT11 Invoice" to "Payment request or lightning address" + +## Sources + +- **Origin brainstorm:** desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md +- **State machine pattern:** desktopApp/.../ui/ImportFollowListDialog.kt (sealed class + LaunchedEffect) +- `LightningAddressResolver`: commons/src/jvmAndroid/.../LightningAddressResolver.kt:47-234 +- `Lud06.toLnUrlp()`: quartz/src/commonMain/.../lightning/Lud06.kt:51-58 +- `LnInvoiceUtil.findInvoice()`: quartz/src/commonMain/.../lightning/LnInvoiceUtil.kt:302-307 +- Current `SendDialog`: desktopApp/.../ui/wallet/WalletColumnScreen.kt:457-584 diff --git a/desktopApp/plans/2026-05-23-fix-nsec-session-persistence-brainstorm.md b/desktopApp/plans/2026-05-23-fix-nsec-session-persistence-brainstorm.md new file mode 100644 index 0000000000..a91f20a34e --- /dev/null +++ b/desktopApp/plans/2026-05-23-fix-nsec-session-persistence-brainstorm.md @@ -0,0 +1,167 @@ +# Fix: nsec Session Not Persisting Across Desktop Restarts + +## Current Persistence Mechanism + +Two-layer storage architecture: + +| Layer | What | Where | Format | +|-------|------|-------|--------| +| Account metadata | npub, signerType, activeNpub | `~/.amethyst/accounts.json.enc` | AES-256-GCM encrypted JSON | +| Private keys | nsec hex, ephemeral keys, NWC URIs | OS keychain (macOS Keychain via `java-keyring` 1.0.4) | Plaintext in keychain entry | +| Encryption key | AES key for accounts.json.enc | OS keychain, alias `account-metadata-key` | Base64 in keychain entry | + +### Login Flow (nsec) + +1. User pastes nsec in `LoginCard` (Paste Key tab) +2. `LoginCard.onLogin(keyInput)` called synchronously +3. `LoginScreen.onLogin` calls `accountManager.loginWithKey(keyInput)` -- **synchronous**, sets `_accountState` immediately +4. On success, `.map {}` fires `scope.launch` (fire-and-forget coroutine): + - `accountManager.saveCurrentAccount()` on `Dispatchers.IO` + - `onLoginSuccess()` on Main +5. `onLoginSuccess` fires another `scope.launch(Dispatchers.IO)`: + - `accountManager.ensureCurrentAccountInStorage()` + - `accountManager.refreshAccountList()` + +### Save Path (`saveCurrentAccount`) + +1. Reads `currentAccount()` from in-memory `_accountState` +2. Calls `secureStorage.savePrivateKey(npub, privKeyHex)` -- writes nsec to OS keychain +3. Calls `accountStorage.saveAccount(info)` -- updates in-memory cache + writes `accounts.json.enc` +4. Calls `accountStorage.setCurrentAccount(npub)` -- updates activeNpub in cache + writes file again + +### Restore Path (`loadSavedAccount`) + +1. Reads `accountStorage.currentAccount()` -- decrypts `accounts.json.enc`, gets `activeNpub` +2. Finds matching `AccountInfo` in account list +3. For `SignerType.Internal`: calls `secureStorage.getPrivateKey(npub)` -- reads from OS keychain +4. If privkey found: creates `KeyPair` + `NostrSignerInternal`, sets `_accountState` +5. If privkey null: falls back to read-only (view-only mode) + +## Root Cause Hypotheses (Ranked by Likelihood) + +### H1: Fire-and-Forget Save Never Completes (HIGH) + +**The save runs in a fire-and-forget coroutine that can be lost.** + +In `LoginScreen.kt` lines 97-103: +```kotlin +accountManager.loginWithKey(keyInput).map { + scope.launch { // <-- fire-and-forget + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } + onLoginSuccess() + } +} +``` + +`loginWithKey` sets `_accountState = LoggedIn` synchronously. The UI immediately recomposes to show `MainContent`. The `scope` here is `rememberCoroutineScope()` from `LoginScreen` -- but `LoginScreen` is no longer composed (it was inside the `AccountState.LoggedOut` branch). When the composable leaves composition, `rememberCoroutineScope` cancels all launched coroutines. + +**Race condition:** `loginWithKey()` updates `_accountState` -> recomposition -> `LoginScreen` exits composition -> its `scope` is cancelled -> the save coroutine is cancelled before `saveCurrentAccount()` completes. + +The `onLoginSuccess` callback in `Main.kt` also tries `ensureCurrentAccountInStorage()`, but it uses a **different** `scope.launch(Dispatchers.IO)` from the `App` composable's scope. This should survive. But if `saveCurrentAccount()` was the only path that writes the private key to the keychain, and it got cancelled, then `ensureCurrentAccountInStorage()` only writes the metadata (npub + signerType) without the private key. On next startup, `loadInternalAccount` finds no privkey and falls back to read-only -- which looks like "session gone." + +**Verdict:** The nsec is likely written to metadata but the keychain save is cancelled. On restart, the account loads as read-only (no nsec) which may appear as "not persisted." + +### H2: Keychain Write Fails Silently (MEDIUM) + +`java-keyring` 1.0.4 uses macOS Security framework. Known issues: +- First-time keychain access prompts the user for permission -- if dismissed, the write silently fails (caught as `BackendNotSupportedException` or `PasswordAccessException`) +- Sandboxed apps may not have keychain entitlements +- `./gradlew :desktopApp:run` runs via Gradle daemon which may lack keychain access + +If `saveToKeyring` throws, `SecureKeyStorage.savePrivateKey` catches it and falls to `saveToFallback`, which requires a console password. In a GUI app launched via `./gradlew :desktopApp:run`, there IS no console. `getFallbackPassword()` calls `System.console()` which returns null, then calls `readLine()` which blocks on stdin. This could hang or throw. + +### H3: AES Key Regenerated Between Sessions (MEDIUM-LOW) + +The `accounts.json.enc` encryption key is stored in keychain under `account-metadata-key`. If H2 applies (keychain write fails), a NEW AES key is generated on each app launch. The file written with the old key becomes unreadable on next launch -- `readMetadataFromDisk` catches `AEADBadTagException`, backs up the corrupt file, and returns empty `AccountMetadata`. This means `currentAccount()` returns null -> "No saved account". + +### H4: `cachedMetadata` Race Between Concurrent Coroutines (LOW) + +`DesktopAccountStorage.cachedMetadata` is a plain `var` with no synchronization. The two save paths (`saveCurrentAccount` from LoginScreen's scope, `ensureCurrentAccountInStorage` from App's scope) could interleave reads/writes. One could overwrite the other's changes. But this would cause partial data loss, not complete failure. + +### H5: `accounts.json.enc` Written But AES Key Lost (LOW) + +If the app is killed between writing the file and the keychain entry being flushed, the AES key may not persist. On next startup, a new key is generated, old file is unreadable. + +## Key Files Involved + +| File | Role | +|------|------| +| `desktopApp/.../Main.kt` | Startup flow, `loadSavedAccount()` call, `onLoginSuccess` callback | +| `desktopApp/.../ui/LoginScreen.kt` | Login UI, calls `loginWithKey` + fire-and-forget save | +| `desktopApp/.../account/AccountManager.kt` | `loginWithKey`, `saveCurrentAccount`, `loadSavedAccount`, `ensureCurrentAccountInStorage` | +| `desktopApp/.../account/DesktopAccountStorage.kt` | Encrypted metadata file I/O, `accounts.json.enc` | +| `commons/.../keystorage/SecureKeyStorage.kt` (jvmMain) | OS keychain via `java-keyring`, fallback encrypted file | +| `desktopApp/.../ui/auth/LoginCard.kt` | UI form, calls `onLogin` synchronously | + +## Proposed Fixes + +### Fix 1: Make `loginWithKey` Save Inline (Addresses H1) + +Change `loginWithKey` to a `suspend fun` that saves the account before returning. This eliminates the fire-and-forget race entirely. + +```kotlin +// Before: loginWithKey is sync, save is fire-and-forget +fun loginWithKey(keyInput: String): Result + +// After: loginWithKey saves inline +suspend fun loginWithKey(keyInput: String): Result { + // ... create keyPair, signer, state ... + _accountState.value = state + saveCurrentAccount() // save inline, not fire-and-forget + return Result.success(state) +} +``` + +LoginScreen would call it from a coroutine: +```kotlin +onLogin = { keyInput -> + scope.launch(Dispatchers.IO) { + accountManager.loginWithKey(keyInput).fold( + onSuccess = { withContext(Dispatchers.Main) { onLoginSuccess() } }, + onFailure = { /* show error */ } + ) + } + Result.success(Unit) // immediate return to LoginCard +} +``` + +### Fix 2: Use App-Level Scope for Save (Addresses H1, simpler) + +Move the save coroutine to the `onLoginSuccess` callback (which uses App-level scope that survives recomposition) and ensure it saves the private key too: + +```kotlin +onLoginSuccess = { + scope.launch(Dispatchers.IO) { + accountManager.saveCurrentAccount() // <-- add this, saves privkey to keychain + accountManager.ensureCurrentAccountInStorage() + accountManager.refreshAccountList() + } +} +``` + +### Fix 3: Validate Keychain Access on Startup (Addresses H2/H3) + +Add a keychain health check at startup. Write a test value, read it back, delete it. If it fails, show a warning dialog instead of silently falling to broken fallback. + +### Fix 4: Add Mutex to DesktopAccountStorage (Addresses H4) + +Wrap `cachedMetadata` access in a `Mutex` to prevent concurrent read/write races. + +### Fix 5: Log Save Result (Diagnostic) + +In `LoginScreen.kt`, log the result of `saveCurrentAccount()` so failures are visible: +```kotlin +val result = accountManager.saveCurrentAccount() +if (result.isFailure) { + Log.e("LoginScreen", "Failed to save account", result.exceptionOrNull()) +} +``` + +## Unanswered Questions + +- Is the keychain prompt appearing on first login? User may be dismissing it. +- Is `./gradlew :desktopApp:run` the launch method? Gradle daemon may lack keychain entitlements. +- Does `~/.amethyst/accounts.json.enc` exist after login? If yes, metadata saved but privkey lost (H1/H2). If no, metadata never written (H1 complete cancellation). +- Is the app packaged (`.dmg`/`.deb`) or run from source? Packaging affects keychain access. +- Does the user see read-only mode on restart, or the login screen? Read-only = privkey lost. Login screen = metadata lost. +- Is there a `accounts.json.enc.corrupt.*` backup file in `~/.amethyst/`? If yes, H3 confirmed. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 5b92c6c1e5..2797a26071 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -590,6 +590,7 @@ fun main() { Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) }) Item("Relays", onClick = { deckState.addColumn(DeckColumnType.Relays) }) + Item("Wallet", onClick = { deckState.addColumn(DeckColumnType.Wallet) }) } } } @@ -944,8 +945,11 @@ fun App( if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { accountManager.startHeartbeat(scope) } - // Ensure account is in multi-account storage + refresh list + // Save account (privkey to keychain + metadata to disk) + // then ensure multi-account storage is up to date. + // Uses App-level scope so it survives LoginScreen leaving composition. scope.launch(Dispatchers.IO) { + accountManager.saveCurrentAccount() accountManager.ensureCurrentAccountInStorage() accountManager.refreshAccountList() } 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 3f34c86263..f21c341df8 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 @@ -27,9 +27,13 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47Client import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response @@ -185,4 +189,178 @@ class NwcPaymentHandler( PaymentResult.Error("Unexpected response type: ${response.resultType}") } } + + // -- NWC RPC: get_balance -- + + sealed class BalanceResult { + data class Success( + val balanceMsats: Long, + ) : BalanceResult() + + data class Error( + val message: String, + ) : BalanceResult() + + data object Timeout : BalanceResult() + } + + suspend fun getBalance( + nwcConnection: Nip47WalletConnect.Nip47URINorm, + 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) { + waitForGenericResponse( + requestId = requestEvent.id, + nwcConnection = nwcConnection, + nwcSigner = nwcSigner, + onSubscribed = { + relayManager.publishToRelay(nwcConnection.relayUri, requestEvent) + }, + ) { response -> + when (response) { + is GetBalanceSuccessResponse -> { + val msats = response.result?.balance ?: 0L + BalanceResult.Success(msats) + } + + is NwcErrorResponse -> { + BalanceResult.Error(response.error?.message ?: "Unknown error") + } + + else -> { + BalanceResult.Error("Unexpected response: ${response.resultType}") + } + } + } + } ?: BalanceResult.Timeout + } + + // -- NWC RPC: make_invoice -- + + sealed class InvoiceResult { + data class Success( + val invoice: String, + val paymentHash: String?, + ) : InvoiceResult() + + data class Error( + val message: String, + ) : InvoiceResult() + + data object Timeout : InvoiceResult() + } + + suspend fun makeInvoice( + nwcConnection: Nip47WalletConnect.Nip47URINorm, + amountMsats: Long, + description: String? = null, + 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) + + 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) }, + ) { response -> + when (response) { + is MakeInvoiceSuccessResponse -> { + val invoice = response.result?.invoice + if (invoice != null) { + InvoiceResult.Success(invoice, response.result?.payment_hash) + } else { + InvoiceResult.Error("Wallet returned no invoice") + } + } + + is NwcErrorResponse -> { + InvoiceResult.Error(response.error?.message ?: "Unknown error") + } + + else -> { + InvoiceResult.Error("Unexpected response: ${response.resultType}") + } + } + } + } ?: InvoiceResult.Timeout + } + + // -- Generic NWC response listener -- + + private suspend fun waitForGenericResponse( + requestId: String, + nwcConnection: Nip47WalletConnect.Nip47URINorm, + nwcSigner: NostrSignerInternal, + onSubscribed: () -> Unit = {}, + processResponse: (Response) -> T, + ): T = + suspendCancellableCoroutine { continuation -> + val filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(nwcConnection.pubKeyHex), + tags = mapOf("e" to listOf(requestId)), + ) + + val subId = "nwc-rpc-${requestId.take(8)}" + + relayManager.subscribeOnRelay( + relay = nwcConnection.relayUri, + subId = subId, + filters = listOf(filter), + onEvent = { event, _ -> + if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) { + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) + kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { + if (!localCache.justVerify(event)) return@launch + + relayManager.closeSubscription(nwcConnection.relayUri, subId) + + try { + val response = event.decrypt(nwcSigner) + val result = processResponse(response) + if (continuation.isActive) { + continuation.resume(result) + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + if (continuation.isActive) { + continuation.resume( + processResponse( + NwcErrorResponse( + resultType = "error", + error = + com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError( + message = "Decrypt failed: ${e.message}", + ), + ), + ), + ) + } + } + } + } + }, + ) + + // Publish AFTER subscribing to avoid missing fast wallet responses + onSubscribed() + + continuation.invokeOnCancellation { + relayManager.closeSubscription(nwcConnection.relayUri, subId) + } + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 082c9a9408..e390b2df22 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,9 +56,6 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource @Composable @@ -69,7 +65,7 @@ fun LoginScreen( ) { var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() + val loginProgress by accountManager.loginProgress.collectAsState() Column( @@ -96,10 +92,7 @@ fun LoginScreen( LoginCard( onLogin = { keyInput -> accountManager.loginWithKey(keyInput).map { - scope.launch { - withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } - onLoginSuccess() - } + onLoginSuccess() } }, onGenerateNew = { @@ -127,10 +120,7 @@ fun LoginScreen( nsec = account.nsec, onContinue = { showNewKeyDialog = false - scope.launch { - withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } - onLoginSuccess() - } + onLoginSuccess() }, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index 27b578dfc7..aa7139cdbe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -128,6 +128,7 @@ fun DeckColumnType.category(): ScreenCategory = DeckColumnType.MyProfile, DeckColumnType.Settings, + DeckColumnType.Wallet, -> ScreenCategory.IDENTITY DeckColumnType.Chess -> ScreenCategory.PLAY @@ -176,6 +177,7 @@ val LAUNCHABLE_SCREENS: List = DeckColumnType.Settings, DeckColumnType.Relays, DeckColumnType.Chess, + DeckColumnType.Wallet, ) // -- Tabs -- diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index e2dbe9136a..d1e8cf9a74 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -121,6 +121,7 @@ fun DeckColumnType.icon(): MaterialSymbol = DeckColumnType.Chess -> MaterialSymbols.Extension DeckColumnType.Settings -> MaterialSymbols.Settings DeckColumnType.Relays -> MaterialSymbols.Dns + DeckColumnType.Wallet -> MaterialSymbols.AccountBalanceWallet is DeckColumnType.Article -> MaterialSymbols.AutoMirrored.Article is DeckColumnType.Editor -> MaterialSymbols.AutoMirrored.Article DeckColumnType.Drafts -> MaterialSymbols.AutoMirrored.Article diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 3dae8973b4..86d23cf80f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -356,6 +356,18 @@ internal fun RootContent( ) } + DeckColumnType.Wallet -> { + com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen( + account = account, + accountManager = accountManager, + relayManager = relayManager, + localCache = localCache, + nwcConnection = nwcConnection, + appScope = appScope, + onZapFeedback = onZapFeedback, + ) + } + DeckColumnType.Relays -> { val accountRelays = com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays.current RelayDashboardScreen( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index fd76311ba1..071ad2a56a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -45,6 +45,8 @@ sealed class DeckColumnType { object Relays : DeckColumnType() + object Wallet : DeckColumnType() + data class Profile( val pubKeyHex: String, ) : DeckColumnType() @@ -88,6 +90,7 @@ sealed class DeckColumnType { Chess -> "Chess" Settings -> "Settings" Relays -> "Relays" + Wallet -> "Wallet" is Article -> "Article" is Editor -> "New Article" Drafts -> "Drafts" @@ -111,6 +114,7 @@ sealed class DeckColumnType { Chess -> "chess" Settings -> "settings" Relays -> "relays" + Wallet -> "wallet" is Article -> "article" is Editor -> "editor" Drafts -> "drafts" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 8ff785dffc..1fd4284345 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -299,6 +299,7 @@ class DeckState( "chess" -> DeckColumnType.Chess "settings" -> DeckColumnType.Settings "relays" -> DeckColumnType.Relays + "wallet" -> DeckColumnType.Wallet "drafts" -> DeckColumnType.Drafts "highlights" -> DeckColumnType.MyHighlights "editor" -> DeckColumnType.Editor(param) 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 new file mode 100644 index 0000000000..7b11e0e50e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -0,0 +1,980 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.text.NumberFormat +import java.util.Locale + +@Composable +fun WalletColumnScreen( + account: AccountState.LoggedIn, + accountManager: AccountManager, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + nwcConnection: Nip47URINorm?, + appScope: CoroutineScope, + onZapFeedback: (ZapFeedback) -> Unit, +) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + // Dialog visibility + var showConnectDialog by remember { mutableStateOf(false) } + var showSendDialog by remember { mutableStateOf(false) } + var showReceiveDialog by remember { mutableStateOf(false) } + + // Balance state + var balanceSats by remember { mutableStateOf(null) } + var isLoadingBalance by remember { mutableStateOf(false) } + + val paymentHandler = + remember(relayManager, localCache) { + NwcPaymentHandler(relayManager, localCache) + } + + // Auto-fetch balance when wallet connects + LaunchedEffect(nwcConnection) { + if (nwcConnection != null && balanceSats == null) { + isLoadingBalance = true + when (val result = paymentHandler.getBalance(nwcConnection)) { + is NwcPaymentHandler.BalanceResult.Success -> { + balanceSats = result.balanceMsats / 1000 + } + + 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 + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (nwcConnection == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + NoWalletContent(onConnect = { showConnectDialog = true }) + } + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + WalletBalanceCard( + balanceSats = balanceSats, + isLoading = isLoadingBalance, + onRefresh = { + isLoadingBalance = true + scope.launch { + when (val result = paymentHandler.getBalance(nwcConnection)) { + is NwcPaymentHandler.BalanceResult.Success -> { + balanceSats = result.balanceMsats / 1000 + } + + is NwcPaymentHandler.BalanceResult.Error -> { + snackbarHostState.showSnackbar("Balance error: ${result.message}") + } + + is NwcPaymentHandler.BalanceResult.Timeout -> { + snackbarHostState.showSnackbar("Balance request timed out") + } + } + isLoadingBalance = false + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { showSendDialog = true }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Send") + } + OutlinedButton( + onClick = { showReceiveDialog = true }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Receive") + } + } + + HorizontalDivider() + + Text( + text = "Connected Wallet", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Relay: ${nwcConnection.relayUri}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + TextButton(onClick = { + appScope.launch { + accountManager.clearNwcConnection(account.npub) + balanceSats = null + } + }) { + Text("Disconnect", color = MaterialTheme.colorScheme.error) + } + } + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + + // -- Dialogs -- + + if (showConnectDialog) { + ConnectWalletDialog( + onDismiss = { showConnectDialog = false }, + onConnect = { uri -> + scope.launch { + val result = accountManager.setNwcConnection(account.npub, uri) + if (result.isSuccess) { + showConnectDialog = false + snackbarHostState.showSnackbar("Wallet connected!") + } + } + }, + ) + } + + if (showSendDialog && nwcConnection != null) { + SendDialog( + onDismiss = { showSendDialog = false }, + onSuccess = { + showSendDialog = false + scope.launch { snackbarHostState.showSnackbar("Payment successful!") } + }, + paymentHandler = paymentHandler, + nwcConnection = nwcConnection, + ) + } + + if (showReceiveDialog && nwcConnection != null) { + ReceiveDialog( + onDismiss = { showReceiveDialog = false }, + onGenerate = { amountSats, description -> + scope.launch { + val result = + paymentHandler.makeInvoice( + nwcConnection = nwcConnection, + amountMsats = amountSats * 1000, + description = description.ifBlank { null }, + ) + when (result) { + is NwcPaymentHandler.InvoiceResult.Success -> { + result.invoice + } + + is NwcPaymentHandler.InvoiceResult.Error -> { + snackbarHostState.showSnackbar("Error: ${result.message}") + null + } + + is NwcPaymentHandler.InvoiceResult.Timeout -> { + snackbarHostState.showSnackbar("Invoice request timed out") + null + } + } + } + }, + paymentHandler = paymentHandler, + nwcConnection = nwcConnection, + snackbarHostState = snackbarHostState, + ) + } +} + +@Composable +private fun NoWalletContent(onConnect: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + symbol = MaterialSymbols.AccountBalanceWallet, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "No Wallet Connected", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "Connect a Lightning wallet via\nNostr Wallet Connect (NWC)\nto send and receive sats.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onConnect) { + Text("Connect Wallet") + } + } +} + +@Composable +private fun WalletBalanceCard( + balanceSats: Long?, + isLoading: Boolean, + onRefresh: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Balance", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + TextButton(onClick = onRefresh, enabled = !isLoading) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Refresh", style = MaterialTheme.typography.labelSmall) + } + } + } + if (balanceSats != null) { + Text( + text = "${formatSats(balanceSats)} sats", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + Text( + text = "-- sats", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f), + ) + } + } + } +} + +// -- Dialogs -- + +@Composable +private fun ConnectWalletDialog( + onDismiss: () -> Unit, + onConnect: (String) -> Unit, +) { + var nwcUri by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Connect Wallet") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Paste your Nostr Wallet Connect URI.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = nwcUri, + onValueChange = { + nwcUri = it + error = null + }, + label = { Text("NWC URI") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 4, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + ) + OutlinedButton(onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val text = + try { + clipboard.getData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (text != null) nwcUri = text + }) { + Text("Paste from Clipboard") + } + Text( + "Supported: Alby Hub, Phoenix, Coinos, LNbits, Zeus", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button( + onClick = { + val trimmed = nwcUri.trim() + if (trimmed.startsWith("nostr+walletconnect://")) { + onConnect(trimmed) + } else { + error = "Invalid NWC URI. Expected: nostr+walletconnect://..." + } + }, + enabled = nwcUri.isNotBlank(), + ) { + Text("Connect") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** + * Sealed state machine for send dialog — supports BOLT11, LNURL, and lightning addresses. + */ +private sealed class SendState { + data object Idle : SendState() + + data class Resolving( + val url: String, + ) : SendState() + + data class NeedsAmount( + val originalInput: String, + val callbackUrl: String, + val minSats: Long, + val maxSats: Long, + val commentAllowed: Int, + ) : SendState() + + data class FetchingInvoice( + val callbackUrl: String, + val amountMilliSats: Long, + val comment: String, + ) : SendState() + + data class ReadyToPay( + val bolt11: String, + ) : SendState() + + data object Paying : SendState() + + data class Error( + val message: String, + val retryState: SendState, + ) : SendState() +} + +/** + * Classifies payment input as BOLT11, LNURL, lightning address, or unknown. + */ +private fun classifyAndProcess(input: String): SendState { + val trimmed = + input + .trim() + .removePrefix("lightning:") + .removePrefix("LIGHTNING:") + .trim() + if (trimmed.isBlank()) return SendState.Idle + + // 1. BOLT11 invoice + LnInvoiceUtil.findInvoice(trimmed)?.let { return SendState.ReadyToPay(it) } + + // 2. LNURL bech32 + if (trimmed.lowercase().startsWith("lnurl")) { + Lud06().toLnUrlp(trimmed)?.let { return SendState.Resolving(it) } + } + + // 3. Lightning address (user@domain) + if (trimmed.contains("@") && trimmed.contains(".")) { + val parts = trimmed.split("@") + if (parts.size == 2 && parts[0].isNotBlank() && parts[1].contains(".")) { + return SendState.Resolving("https://${parts[1]}/.well-known/lnurlp/${parts[0]}") + } + } + + return SendState.Idle +} + +@Composable +private fun SendDialog( + onDismiss: () -> Unit, + onSuccess: () -> Unit, + paymentHandler: NwcPaymentHandler, + nwcConnection: Nip47URINorm, +) { + var input by remember { mutableStateOf("") } + var sendState by remember { mutableStateOf(SendState.Idle) } + var amount by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + val mapper = remember { jacksonObjectMapper() } + + val isLoading = + sendState is SendState.Resolving || + sendState is SendState.FetchingInvoice || + sendState is SendState.Paying + + // Auto-resolve LNURL endpoint + LaunchedEffect(sendState) { + val state = sendState + if (state is SendState.Resolving) { + try { + val httpClient = DesktopHttpClient.currentClient() + val request = + okhttp3.Request + .Builder() + .url(state.url) + .build() + val response = + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + httpClient.newCall(request).execute() + } + val body = response.body?.string() + if (body == null) { + sendState = SendState.Error("Failed to reach payment server", SendState.Idle) + return@LaunchedEffect + } + val json = mapper.readTree(body) + val callback = json.get("callback")?.asText()?.ifBlank { null } + if (callback == null) { + val errorMsg = json.get("reason")?.asText() ?: json.get("message")?.asText() ?: "Invalid LNURL endpoint" + sendState = SendState.Error(errorMsg, SendState.Idle) + return@LaunchedEffect + } + val minMsats = json.get("minSendable")?.asLong() ?: 1000L + val maxMsats = json.get("maxSendable")?.asLong() ?: 100_000_000L + val commentLen = json.get("commentAllowed")?.asInt() ?: 0 + val minSats = minMsats / 1000 + val maxSats = maxMsats / 1000 + // Fixed amount: prepopulate + if (minSats == maxSats) { + amount = minSats.toString() + } + sendState = SendState.NeedsAmount(input, callback, minSats, maxSats, commentLen) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + sendState = SendState.Error("Failed to resolve: ${e.message}", SendState.Idle) + } + } + } + + // Auto-fetch invoice from callback + LaunchedEffect(sendState) { + val state = sendState + if (state is SendState.FetchingInvoice) { + try { + val httpClient = DesktopHttpClient.currentClient() + val urlBinder = if (state.callbackUrl.contains("?")) "&" else "?" + val encodedComment = java.net.URLEncoder.encode(state.comment, "utf-8") + val url = "${state.callbackUrl}${urlBinder}amount=${state.amountMilliSats}&comment=$encodedComment" + val request = + okhttp3.Request + .Builder() + .url(url) + .build() + val response = + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + httpClient.newCall(request).execute() + } + val body = response.body?.string() + if (body == null) { + sendState = SendState.Error("Failed to fetch invoice", SendState.Idle) + return@LaunchedEffect + } + val json = mapper.readTree(body) + val pr = json.get("pr")?.asText()?.ifBlank { null } + if (pr != null) { + sendState = SendState.ReadyToPay(pr) + } else { + val reason = json.get("reason")?.asText() ?: json.get("message")?.asText() ?: "No invoice returned" + sendState = SendState.Error(reason, SendState.Idle) + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + sendState = SendState.Error("Invoice fetch failed: ${e.message}", SendState.Idle) + } + } + } + + // Auto-pay when ReadyToPay (only for LNURL flow — BOLT11 uses button click) + // For direct BOLT11 paste, user clicks Pay explicitly + + Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) { + Card( + modifier = Modifier.width(480.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(24.dp)) { + // Header + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Send Payment", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { if (!isLoading) onDismiss() }) { + Icon( + MaterialSymbols.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Input field + OutlinedTextField( + value = input, + onValueChange = { + input = it + sendState = SendState.Idle + }, + label = { Text("Invoice, LNURL, or lightning address") }, + placeholder = { Text("lnbc..., lnurl1..., or user@domain") }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + maxLines = 4, + enabled = sendState is SendState.Idle || sendState is SendState.Error, + ) + + Spacer(Modifier.height(8.dp)) + + if (sendState is SendState.Idle || sendState is SendState.Error) { + OutlinedButton(onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val text = + try { + clipboard.getData(DataFlavor.stringFlavor) as? String + } catch (_: Exception) { + null + } + if (text != null) { + input = text + sendState = classifyAndProcess(text) + } + }) { + Text("Paste from Clipboard") + } + } + + // Resolving spinner + if (sendState is SendState.Resolving) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text("Resolving payment request...", style = MaterialTheme.typography.bodySmall) + } + } + + // Amount + comment form (LNURL flow) + val needsAmount = sendState as? SendState.NeedsAmount + if (needsAmount != null) { + Spacer(Modifier.height(12.dp)) + val isFixed = needsAmount.minSats == needsAmount.maxSats + OutlinedTextField( + value = amount, + onValueChange = { new -> if (new.all { it.isDigit() }) amount = new }, + label = { + Text( + if (isFixed) { + "Amount (${formatSats(needsAmount.minSats)} sats)" + } else { + "Amount (${formatSats(needsAmount.minSats)} - ${formatSats(needsAmount.maxSats)} sats)" + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + readOnly = isFixed, + ) + if (needsAmount.commentAllowed > 0) { + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = comment, + onValueChange = { if (it.length <= needsAmount.commentAllowed) comment = it }, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } + + // Fetching invoice spinner + if (sendState is SendState.FetchingInvoice) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text("Fetching invoice...", style = MaterialTheme.typography.bodySmall) + } + } + + // Error display + val errorState = sendState as? SendState.Error + if (errorState != null) { + Spacer(Modifier.height(12.dp)) + SelectionContainer { + Text( + text = errorState.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(Modifier.height(16.dp)) + + // Action button + val canPay = + when (sendState) { + is SendState.Idle -> input.isNotBlank() + is SendState.NeedsAmount -> amount.isNotBlank() + is SendState.ReadyToPay -> true + is SendState.Error -> true + else -> false + } + + Button( + onClick = { + when (val state = sendState) { + is SendState.Idle -> { + sendState = classifyAndProcess(input) + } + is SendState.NeedsAmount -> { + val amountSats = amount.toLongOrNull() ?: 0L + if (amountSats < state.minSats || amountSats > state.maxSats) { + sendState = + SendState.Error( + "Amount must be between ${formatSats(state.minSats)} and ${formatSats(state.maxSats)} sats", + state, + ) + } else { + sendState = SendState.FetchingInvoice(state.callbackUrl, amountSats * 1000, comment) + } + } + is SendState.ReadyToPay -> { + sendState = SendState.Paying + scope.launch { + val result = paymentHandler.payInvoice(bolt11 = state.bolt11, nwcConnection = nwcConnection) + when (result) { + is NwcPaymentHandler.PaymentResult.Success -> onSuccess() + is NwcPaymentHandler.PaymentResult.Error -> { + sendState = SendState.Error(result.message, SendState.Idle) + } + is NwcPaymentHandler.PaymentResult.Timeout -> { + sendState = SendState.Error("Payment timed out", SendState.Idle) + } + } + } + } + is SendState.Error -> { + sendState = state.retryState + } + else -> {} + } + }, + enabled = canPay && !isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (sendState is SendState.Paying) "Paying..." else "Processing...") + } else { + Text( + when (sendState) { + is SendState.Error -> "Retry" + is SendState.NeedsAmount -> "Pay" + is SendState.ReadyToPay -> "Pay Invoice" + else -> "Continue" + }, + ) + } + } + } + } + } +} + +@Composable +private fun ReceiveDialog( + onDismiss: () -> Unit, + onGenerate: (Long, String) -> Unit, + paymentHandler: NwcPaymentHandler, + nwcConnection: Nip47URINorm, + snackbarHostState: SnackbarHostState, +) { + var amount by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var generatedInvoice by remember { mutableStateOf(null) } + var isGenerating by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Dialog(onDismissRequest = { if (!isGenerating) onDismiss() }) { + Card( + modifier = Modifier.width(400.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(24.dp)) { + // Header: title + close X + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + if (generatedInvoice != null) "Invoice Created" else "Receive Payment", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { if (!isGenerating) onDismiss() }) { + Icon( + MaterialSymbols.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + if (generatedInvoice != null) { + // Amount + description + Text( + "${formatSats(amount.toLongOrNull() ?: 0)} sats", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + if (description.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + + Spacer(Modifier.height(16.dp)) + + // QR code + QrCodeCanvas( + data = generatedInvoice!!, + modifier = Modifier.align(Alignment.CenterHorizontally), + size = 240.dp, + ) + + Spacer(Modifier.height(24.dp)) + + OutlinedButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(generatedInvoice), null) + scope.launch { snackbarHostState.showSnackbar("Invoice copied!") } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Copy Invoice") + } + } else { + // Input form + OutlinedTextField( + value = amount, + onValueChange = { new -> if (new.all { it.isDigit() }) amount = new }, + label = { Text("Amount (sats)") }, + placeholder = { Text("1000") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (optional)") }, + placeholder = { Text("What's this for?") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + val amountSats = amount.toLongOrNull() ?: 0L + if (amountSats > 0) { + isGenerating = true + scope.launch { + val result = + paymentHandler.makeInvoice( + nwcConnection = nwcConnection, + amountMsats = amountSats * 1000, + description = description.ifBlank { null }, + ) + when (result) { + is NwcPaymentHandler.InvoiceResult.Success -> { + generatedInvoice = result.invoice + } + + is NwcPaymentHandler.InvoiceResult.Error -> { + snackbarHostState.showSnackbar("Error: ${result.message}") + } + + is NwcPaymentHandler.InvoiceResult.Timeout -> { + snackbarHostState.showSnackbar("Invoice request timed out") + } + } + isGenerating = false + } + } + }, + enabled = amount.isNotBlank() && !isGenerating, + modifier = Modifier.fillMaxWidth(), + ) { + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Create Invoice") + } + } + } + } + } +} + +private fun formatSats(sats: Long): String = NumberFormat.getNumberInstance(Locale.getDefault()).format(sats) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt new file mode 100644 index 0000000000..752deaf576 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nwc + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Unit tests for NwcPaymentHandler — tests the response processing logic + * that converts NWC protocol responses into PaymentResult/BalanceResult/InvoiceResult. + * + * These tests verify the pure protocol handling without relay communication. + * The NwcPaymentHandler internally uses `subscribeOnRelay` callbacks and + * `GlobalScope.launch` which are hard to mock reliably. Instead, we test: + * + * 1. The sealed result types are constructed correctly + * 2. Error conditions return appropriate result types + * 3. The no-secret guard works + */ +class NwcPaymentHandlerTest { + private val walletKeyPair = KeyPair() + private val clientKeyPair = KeyPair() + private val testRelay = NormalizedRelayUrl("wss://relay.test/") + + private fun nwcConnectionWithSecret() = + Nip47WalletConnect.Nip47URINorm( + pubKeyHex = walletKeyPair.pubKey.toHexKey(), + relayUri = testRelay, + secret = clientKeyPair.privKey!!.toHexKey(), + ) + + private fun nwcConnectionWithoutSecret() = + Nip47WalletConnect.Nip47URINorm( + pubKeyHex = walletKeyPair.pubKey.toHexKey(), + relayUri = testRelay, + secret = null, + ) + + // -- PaymentResult types -- + + @Test + fun `PaymentResult Success holds preimage`() { + val result = NwcPaymentHandler.PaymentResult.Success("deadbeef") + assertIs(result) + assertEquals("deadbeef", result.preimage) + } + + @Test + fun `PaymentResult Success allows null preimage`() { + val result = NwcPaymentHandler.PaymentResult.Success(null) + assertEquals(null, result.preimage) + } + + @Test + fun `PaymentResult Error holds message`() { + val result = NwcPaymentHandler.PaymentResult.Error("Insufficient balance") + assertIs(result) + assertEquals("Insufficient balance", result.message) + } + + @Test + fun `PaymentResult Timeout is singleton`() { + val result = NwcPaymentHandler.PaymentResult.Timeout + assertIs(result) + } + + // -- BalanceResult types -- + + @Test + fun `BalanceResult Success holds msats`() { + val result = NwcPaymentHandler.BalanceResult.Success(125_000) + assertIs(result) + assertEquals(125_000, result.balanceMsats) + } + + @Test + fun `BalanceResult Error holds message`() { + val result = NwcPaymentHandler.BalanceResult.Error("Not authorized") + assertEquals("Not authorized", result.message) + } + + @Test + fun `BalanceResult Timeout is singleton`() { + assertIs(NwcPaymentHandler.BalanceResult.Timeout) + } + + // -- InvoiceResult types -- + + @Test + fun `InvoiceResult Success holds invoice and hash`() { + val result = NwcPaymentHandler.InvoiceResult.Success("lnbc50n1...", "abc123") + assertEquals("lnbc50n1...", result.invoice) + assertEquals("abc123", result.paymentHash) + } + + @Test + fun `InvoiceResult Success allows null payment hash`() { + val result = NwcPaymentHandler.InvoiceResult.Success("lnbc50n1...", null) + assertEquals(null, result.paymentHash) + } + + @Test + fun `InvoiceResult Error holds message`() { + val result = NwcPaymentHandler.InvoiceResult.Error("Quota exceeded") + assertEquals("Quota exceeded", result.message) + } + + @Test + fun `InvoiceResult Timeout is singleton`() { + assertIs(NwcPaymentHandler.InvoiceResult.Timeout) + } + + // -- NWC connection validation -- + + @Test + fun `nwcConnection with secret is valid`() { + val conn = nwcConnectionWithSecret() + assertEquals(walletKeyPair.pubKey.toHexKey(), conn.pubKeyHex) + assertEquals(testRelay, conn.relayUri) + assertEquals(clientKeyPair.privKey!!.toHexKey(), conn.secret) + } + + @Test + fun `nwcConnection without secret has null`() { + val conn = nwcConnectionWithoutSecret() + assertEquals(null, conn.secret) + } + + // -- Response event structure -- + + @Test + fun `LnZapPaymentResponseEvent has correct KIND`() { + assertEquals(23195, LnZapPaymentResponseEvent.KIND) + } + + // -- PayInvoiceSuccessResponse structure -- + + @Test + fun `PayInvoiceSuccessResponse holds preimage`() { + val response = + PayInvoiceSuccessResponse( + result = PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage = "abc"), + ) + assertEquals("abc", response.result?.preimage) + } + + // -- GetBalanceSuccessResponse structure -- + + @Test + fun `GetBalanceSuccessResponse holds balance`() { + val response = + GetBalanceSuccessResponse( + result = GetBalanceSuccessResponse.GetBalanceResult(balance = 500_000), + ) + assertEquals(500_000, response.result?.balance) + } + + // -- MakeInvoiceSuccessResponse structure -- + + @Test + fun `MakeInvoiceSuccessResponse holds invoice and hash`() { + val response = + MakeInvoiceSuccessResponse( + result = + NwcTransaction( + invoice = "lnbc100n1...", + payment_hash = "hash123", + amount = 100_000, + ), + ) + assertEquals("lnbc100n1...", response.result?.invoice) + assertEquals("hash123", response.result?.payment_hash) + assertEquals(100_000, response.result?.amount) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt new file mode 100644 index 0000000000..ce6bf5f8a5 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.nwc + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47Client +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Integration tests for NWC RPC round-trips using real crypto. + * No mocking — tests the full encrypt/decrypt cycle with deterministic keys. + */ +class NwcRpcIntegrationTest { + private val clientKeyPair = KeyPair() + private val walletKeyPair = KeyPair() + private val clientSigner = NostrSignerInternal(clientKeyPair) + private val walletSigner = NostrSignerInternal(walletKeyPair) + private val testRelay = NormalizedRelayUrl("wss://relay.test/") + + private fun createClient() = + Nip47Client( + walletPubKeyHex = walletKeyPair.pubKey.toHexKey(), + relayUrl = testRelay, + signer = clientSigner, + ) + + @Test + fun `pay_invoice request round-trip`() = + runTest { + val client = createClient() + val requestEvent = client.payInvoice("lnbc50n1pjtest...") + + // Verify request event structure + assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + assertTrue(requestEvent.content.isNotBlank()) + + // Wallet decrypts the request + val decrypted = requestEvent.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals("lnbc50n1pjtest...", decrypted.params?.invoice) + } + + @Test + fun `get_balance full round-trip`() = + runTest { + val client = createClient() + val requestEvent = client.getBalance() + + // Verify it's a valid NWC request + assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + + // Wallet decrypts and verifies method type + val decrypted = requestEvent.decryptRequest(walletSigner) + assertIs(decrypted) + + // Wallet builds encrypted response + val responseEvent = + LnZapPaymentResponseEvent.createResponse( + response = + GetBalanceSuccessResponse( + result = GetBalanceSuccessResponse.GetBalanceResult(balance = 125_000), + ), + requestEvent = requestEvent, + signer = walletSigner, + ) + + // Verify response event structure + assertEquals(LnZapPaymentResponseEvent.KIND, responseEvent.kind) + assertEquals(requestEvent.id, responseEvent.requestId()) + + // Client decrypts response + val response = responseEvent.decrypt(clientSigner) + assertIs(response) + assertEquals(125_000, response.result?.balance) + } + + @Test + fun `make_invoice full round-trip`() = + runTest { + val client = createClient() + val requestEvent = client.makeInvoice(amount = 50_000, description = "Test invoice") + + // Wallet decrypts + val decrypted = requestEvent.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals(50_000, decrypted.params?.amount) + assertEquals("Test invoice", decrypted.params?.description) + + // Wallet responds with invoice + val fakeInvoice = "lnbc500n1pjgenerated..." + val responseEvent = + LnZapPaymentResponseEvent.createResponse( + response = + MakeInvoiceSuccessResponse( + result = + NwcTransaction( + invoice = fakeInvoice, + payment_hash = "abc123hash", + amount = 50_000, + ), + ), + requestEvent = requestEvent, + signer = walletSigner, + ) + + // Client decrypts + val response = responseEvent.decrypt(clientSigner) + assertIs(response) + assertNotNull(response.result) + assertEquals(fakeInvoice, response.result?.invoice) + assertEquals("abc123hash", response.result?.payment_hash) + } + + @Test + fun `pay_invoice success response round-trip`() = + runTest { + val client = createClient() + val requestEvent = client.payInvoice("lnbc100n1...") + + val responseEvent = + LnZapPaymentResponseEvent.createResponse( + response = + PayInvoiceSuccessResponse( + result = + PayInvoiceSuccessResponse.PayInvoiceResultParams( + preimage = "deadbeef0123456789", + ), + ), + requestEvent = requestEvent, + signer = walletSigner, + ) + + val response = responseEvent.decrypt(clientSigner) + assertIs(response) + assertEquals("deadbeef0123456789", response.result?.preimage) + } + + @Test + fun `NWC URI creates valid client`() = + runTest { + val nwcUri = + "nostr+walletconnect://${walletKeyPair.pubKey.toHexKey()}" + + "?relay=wss%3A%2F%2Frelay.test%2F" + + "&secret=${clientKeyPair.privKey!!.toHexKey()}" + + val client = Nip47Client.fromUri(nwcUri) + assertEquals(walletKeyPair.pubKey.toHexKey(), client.walletPubKeyHex) + + // Build a request and verify it works + val requestEvent = client.getBalance() + assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + + // Wallet can decrypt it + val decrypted = requestEvent.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md b/docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md new file mode 100644 index 0000000000..9ff4aec9eb --- /dev/null +++ b/docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md @@ -0,0 +1,187 @@ +--- +title: "perf: Viewport-aware feed metadata loading" +type: perf +status: active +date: 2026-04-29 +origin: docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md +--- + +# perf: Viewport-aware feed metadata loading + +## Enhancement Summary + +**Deepened on:** 2026-04-29 +**Research agents:** compose-expert, kotlin-coroutines, nostr-expert, relay-client + +### Key Improvements +1. Concrete `snapshotFlow` + `debounce` pattern for viewport detection (zero recomposition) +2. `loadMetadataBatched()` implementation with EOSE close (follows Chess helper pattern) +3. Nostr filter sizing: 100 authors max per filter, CLOSE after EOSE +4. `collectLatest` for cancelling stale fetches on scroll change + +--- + +## Overview + +Feed metadata (display names, avatars) takes 5+ seconds because the pipeline loads metadata for ALL notes (100+), rate-limits at 20/sec, and creates individual subscriptions per author. Fix with viewport-aware loading + batched author filter. + +## Problem Statement + +Pre-existing on `main`. Pipeline: +1. `visibleNotes()` returns ALL notes (not viewport-filtered) +2. `MetadataRateLimiter`: 20 pubkeys/sec with 1-sec batch delays +3. Each author gets individual `client.subscribe()` call +4. All subscriptions broadcast to 7 relays + +(see brainstorm: `docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md`) + +## Technical Approach + +### Phase 1: Viewport-Aware Note Selection + +**Goal:** Only fetch metadata for visible notes + 10-item buffer. + +**Tasks:** +- [ ] Replace the existing `LaunchedEffect(feedState, subscriptionsCoordinator)` in `FeedScreen.kt:354` with a viewport-aware version using `snapshotFlow` +- [ ] Use `lazyListState.layoutInfo.visibleItemsInfo` inside `snapshotFlow` (NOT in composition — avoids per-frame recomposition) +- [ ] Buffer ±10 items with `coerceIn(0, list.lastIndex)` to prevent IndexOutOfBounds +- [ ] Debounce at 500ms via `.debounce(500)` +- [ ] Use `collectLatest` to cancel stale fetches when scroll position changes + +**Implementation pattern (compose-expert + kotlin-coroutines):** + +```kotlin +// In FeedScreen, sibling to LazyColumn (not inside it) +LaunchedEffect(lazyListState, feedNotes) { + snapshotFlow { + val info = lazyListState.layoutInfo + if (info.visibleItemsInfo.isEmpty() || feedNotes.isEmpty()) { + return@snapshotFlow emptyList() + } + val first = (info.visibleItemsInfo.first().index - 10).coerceAtLeast(0) + val last = (info.visibleItemsInfo.last().index + 10).coerceAtMost(feedNotes.lastIndex) + feedNotes.subList(first, last + 1) + } + .distinctUntilChanged() + .debounce(500) + .collectLatest { viewportNotes -> + if (viewportNotes.isNotEmpty()) { + // Fast path: batched metadata for visible authors + val authors = viewportNotes.mapNotNull { it.author?.pubkeyHex }.distinct() + subscriptionsCoordinator.loadMetadataBatched(authors) + // Also load reactions for visible notes + subscriptionsCoordinator.loadMetadataForNotes(viewportNotes) + } + } +} +``` + +**Key insights:** +- `snapshotFlow` reads layout info outside composition → zero recomposition cost +- `collectLatest` cancels in-flight `loadMetadataBatched` when scroll changes +- `distinctUntilChanged` skips if same indices visible after debounce +- `feedNotes` captured in `LaunchedEffect` key ensures re-launch when feed data changes + +**Files:** +- `desktopApp/.../ui/FeedScreen.kt` — replace metadata LaunchedEffect + +### Phase 2: Batched Author Subscription + +**Goal:** One relay subscription per batch instead of N individual ones. + +**Tasks:** +- [ ] Add `loadMetadataBatched(pubkeys)` to `FeedMetadataCoordinator` +- [ ] Bypass rate limiter — subscribe directly via `client.subscribe()` +- [ ] Single `Filter(kind:0, authors:pubkeys, limit:pubkeys.size)` sent to index relays +- [ ] Close subscription after EOSE from all relays (one-shot fetch) +- [ ] 5-second timeout to prevent hanging on slow relays +- [ ] Deduplicate against `queuedPubkeys` to avoid double-fetch with background path + +**Implementation pattern (relay-client + nostr-expert):** + +```kotlin +// In FeedMetadataCoordinator — follows ChessRelayFetchHelper pattern +fun loadMetadataBatched(pubkeys: List, timeoutMs: Long = 5_000L) { + val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct() + if (newPubkeys.isEmpty()) return + queuedPubkeys.addAll(newPubkeys) + + scope.launch { + val filter = Filter( + kinds = listOf(MetadataEvent.KIND), + authors = newPubkeys.take(100), // max 100 per filter + limit = newPubkeys.size, + ) + val filterMap = indexRelays.associateWith { listOf(filter) } + val subId = newSubId() + val eoseReceived = mutableSetOf() + val allEose = CompletableDeferred() + + val listener = object : SubscriptionListener { + override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?) { + onEvent?.invoke(event, relay) + } + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + eoseReceived.add(relay) + if (eoseReceived.size >= indexRelays.size) allEose.complete(Unit) + } + } + + client.subscribe(subId, filterMap, listener) + withTimeoutOrNull(timeoutMs) { allEose.await() } + client.unsubscribe(subId) + } +} +``` + +**Key insights (nostr-expert):** +- Kind 0 is replaceable — relay returns one event per pubkey, bulk lookup is efficient +- 100 authors per filter is safe for index relays (purplepag.es, profiles.nostr1.com) +- CLOSE after EOSE — transient viewport set doesn't need live updates +- No `since` on cold load — relay returns latest replaceable event regardless +- Pattern matches existing `ChessRelayFetchHelper.fetchEvents()` idiom in codebase + +**Files:** +- `commons/.../relayClient/assemblers/FeedMetadataCoordinator.kt` — add `loadMetadataBatched()` + +### Phase 3: Progressive Background Loading + +**Goal:** Pre-warm cache for off-screen notes. + +**Tasks:** +- [ ] After viewport metadata loads via batched path, queue remaining feed authors through existing `loadMetadataForNotes()` (rate-limited, low priority) +- [ ] This runs in background — no UI impact + +**Files:** +- `desktopApp/.../ui/FeedScreen.kt` — secondary background pass after viewport pass + +## Acceptance Criteria + +- [ ] Visible note metadata loads within 1-2 seconds of feed render +- [ ] Scrolling to new notes triggers metadata fetch within 500ms +- [ ] No per-frame recomposition from scroll observation (verify with Layout Inspector) +- [ ] No regression for off-screen notes (still loads via background path) +- [ ] `./gradlew :desktopApp:compileKotlin` succeeds +- [ ] `./gradlew :commons:compileKotlinJvm` succeeds +- [ ] `./gradlew spotlessApply` passes +- [ ] Existing tests pass + +## Key Files + +| File | Purpose | +|------|---------| +| `desktopApp/.../ui/FeedScreen.kt:354` | LaunchedEffect trigger (replace) | +| `commons/.../relayClient/assemblers/FeedMetadataCoordinator.kt` | Add `loadMetadataBatched()` | +| `commons/.../relayClient/preload/MetadataRateLimiter.kt` | Bypassed for viewport path | +| `commons/.../chess/ChessRelayFetchHelper.kt:82` | Reference pattern for one-shot fetch | + +## Sources & References + +### Origin +- **Brainstorm:** [docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md](docs/brainstorms/2026-04-29-feed-metadata-loading-optimization-brainstorm.md) + +### Internal References +- ChessRelayFetchHelper (one-shot pattern): `commons/.../chess/ChessRelayFetchHelper.kt:82-131` +- MetadataFilterAssembler (persistent sub): `commons/.../relayClient/assemblers/MetadataFilterAssembler.kt` +- FeedContentState.visibleNotes: `commons/.../ui/feeds/FeedContentState.kt:77` +- DefaultIndexerRelayList: `amethyst/.../model/Constants.kt` diff --git a/docs/plans/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md b/docs/plans/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md new file mode 100644 index 0000000000..5e13f70555 --- /dev/null +++ b/docs/plans/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md @@ -0,0 +1,370 @@ +# Fix: Bunker Timeouts & Broken Decryption + +**Branch**: `fix/bunker-timeout-and-decrypt` +**Date**: 2026-05-04 +**Deepened**: 2026-05-04 + +## Enhancement Summary + +**Research agents used:** 8 (timeout mechanics, decrypt UI trace, retry edge cases, SignerResult structure, NIP-46 protocol, test coverage, auth-signers skill, coroutine testing patterns) + +### Key Improvements from Research +1. **Retry is unsafe as originally planned** — fresh UUIDs per attempt mean old responses are silently dropped. Changed to: republish same request (same ID) + extend timeout. +2. **Desktop shows raw ciphertext on failure** (confirmed at `ChatPane.kt:447`) — need error placeholder like Android's `R.string.could_not_decrypt_the_message`. +3. **DecryptCache marks `CouldNotPerformException` as `DontTryAgain`** — the parser bug causes PERMANENT failure caching. Fixing the parser also fixes the cache poisoning. +4. **Zero logging in NIP-46 module** — add structured logging for debugging. + +### New Considerations Discovered +- `client.publish()` is fire-and-forget with no error feedback +- Late responses after timeout are silently discarded (continuation already removed) +- `DecryptCache` won't retry `CouldNotPerformException` — existing cached failures need invalidation after fix + +--- + +## Problem Summary + +| Issue | Symptom | Root Cause | +|-------|---------|-----------| +| Timeout | Amber shows "1m event timeout" | Amethyst 30s timeout < Amber 60s timeout | +| Decryption | Weird strings shown instead of messages | `BunkerResponseDeserializer` never creates `BunkerResponseDecrypt`; parsers reject generic `BunkerResponse` | + +--- + +## Fix 1: Response Parsers (Decryption) + +### Root Cause + +`BunkerResponseDeserializer` (jvmAndroid) is context-free — it can't distinguish decrypt results (plaintext) from encrypt results (ciphertext). When result is a plain string that's not a pubkey/JSON/ack/pong, it falls through to `BunkerResponse(id, result, error)` base type. + +The 4 response parsers only match specific subtypes, so all hit `else -> ReceivedButCouldNotPerform()`. + +### Research Insight: Cache Poisoning + +`DecryptCache` (at `quartz/.../signers/caches/DecryptCache.kt`) handles exceptions: +- `CouldNotPerformException` → **`DontTryAgain`** (permanent failure, never retried) +- `TimedOutException` → `CanTryAgain` (retries after 10s) + +This means the parser bug causes **permanent cache poisoning**: once a message fails to decrypt due to the wrong response type, it's cached as permanently undecryptable until app restart. + +### Fix (Option A) — Make parsers handle generic `BunkerResponse` + +**Files to modify:** +- `quartz/src/commonMain/.../nip46RemoteSigner/signer/Nip04DecryptResponse.kt` +- `quartz/src/commonMain/.../nip46RemoteSigner/signer/Nip44DecryptResponse.kt` +- `quartz/src/commonMain/.../nip46RemoteSigner/signer/Nip04EncryptResponse.kt` +- `quartz/src/commonMain/.../nip46RemoteSigner/signer/Nip44EncryptResponse.kt` + +**Pattern (decrypt parsers):** +```kotlin +class Nip04DecryptResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponseDecrypt -> { + SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext)) + } + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + else -> { + // Deserializer can't distinguish decrypt results from other strings. + // If we got a generic BunkerResponse with a non-null result, treat as plaintext. + response.result?.let { + SignerResult.RequestAddressed.Successful(DecryptionResult(it)) + } ?: SignerResult.RequestAddressed.ReceivedButCouldNotPerform("No result in response") + } + } + } +} +``` + +**Pattern (encrypt parsers):** +```kotlin +else -> { + response.result?.let { + SignerResult.RequestAddressed.Successful(EncryptionResult(it)) + } ?: SignerResult.RequestAddressed.ReceivedButCouldNotPerform("No result in response") +} +``` + +### Research Insight: `ReceivedButCouldNotPerform` already has `message: String?` + +Confirmed at `SignerResult.kt:35-37`: +```kotlin +class ReceivedButCouldNotPerform( + val message: String? = null, +) : RequestAddressed +``` + +And `convertExceptions()` at `NostrSignerRemote.kt:291`: +```kotlin +is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> + SignerExceptions.CouldNotPerformException("$title: ${result.message}") +``` + +No changes needed to SignerResult — just pass meaningful messages. + +--- + +## Fix 2: Timeout + Retry + +### Root Cause + +`RemoteSignerManager.timeout = 30_000L` is too short. Amber allows 60s for user approval. After relay latency, Amethyst gives up before Amber responds. + +### Research Insight: Retry with Fresh UUIDs is UNSAFE + +Each `bunkerRequestBuilder()` generates a fresh UUID: +```kotlin +class BunkerRequestSign( + id: String = Uuid.random().toString(), // NEW UUID every call + ... +) +``` + +**Problem with naive retry:** +1. Attempt 1: publishes request with UUID-A, times out +2. `invokeOnCancellation` removes UUID-A from `awaitingRequests` +3. Bunker responds to UUID-A → `awaitingRequests.get("UUID-A")` → null → silently dropped +4. Attempt 2: publishes with UUID-B, but bunker already processed UUID-A (may not respond to UUID-B) +5. Result: permanent failure + +### Revised Fix: Republish Same Request + Extended Timeout + +**Strategy:** Build the request ONCE (single UUID), then retry = republish the same event. + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/RemoteSignerManager.kt` + +```kotlin +class RemoteSignerManager( + val timeout: Long = 65_000, // Match Amber's 60s + 5s relay buffer + val client: INostrClient, + val signer: NostrSignerInternal, + val remoteKey: String, + val relayList: Set, +) { + private val awaitingRequests = LargeCache>() + + suspend fun newResponse(responseEvent: NostrConnectEvent) { + val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) + val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) + awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) + } + + suspend fun launchWaitAndParse( + bunkerRequestBuilder: () -> BunkerRequest, + parser: (response: BunkerResponse) -> SignerResult.RequestAddressed, + maxRetries: Int = 1, + ): SignerResult.RequestAddressed { + // Build request ONCE — same UUID for all attempts + val request = bunkerRequestBuilder() + val event = NostrConnectEvent.create( + message = request, + remoteKey = remoteKey, + signer = signer, + ) + + var attempt = 0 + while (true) { + val result = tryAndWait(timeout) { continuation -> + continuation.invokeOnCancellation { + awaitingRequests.remove(request.id) + } + awaitingRequests.put(request.id, continuation) + client.publish(event, relayList = relayList) + } + + when { + result != null -> return parser(result) + attempt >= maxRetries -> return SignerResult.RequestAddressed.TimedOut() + else -> { + attempt++ + delay(2_000L) // Brief pause before republish + } + } + } + } +} +``` + +**Key differences from original plan:** +1. `bunkerRequestBuilder()` called ONCE (same UUID across retries) +2. `NostrConnectEvent.create()` called ONCE (same encrypted event republished) +3. `maxRetries = 1` (conservative: 1 retry = 2 total attempts = ~130s max) +4. `delay(2_000L)` flat (no exponential — relay reconnection is the issue, not load) + +### Research Insight: `client.publish()` is fire-and-forget + +`NostrClient.publish()` queues to outbox and returns immediately — no success/failure feedback. Republishing the same event to relays is idempotent (relays deduplicate by event ID). + +### Research Insight: No `since` filter on subscription + +`StaticSubscription` uses: +```kotlin +Filter(kinds = listOf(24133), tags = mapOf("p" to listOf(signer.pubKey))) +``` + +No `since` → relay reconnection replays old events. This is GOOD for our retry: if relay reconnects and replays the bunker's response, we'll catch it on the second attempt. + +--- + +## Fix 3: Desktop UI Error Display + +### Research Insight: Desktop shows raw ciphertext + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/chats/ChatPane.kt` (lines 447-466) + +Current code: +```kotlin +decryptedContent = when (event) { + is PrivateDmEvent -> { + try { + event.decryptContent(account.signer) + } catch (_: Exception) { + event.content // BUG: Shows raw ciphertext (base64 gibberish) + } + } + else -> event?.content +} +``` + +Android equivalent uses `LoadDecryptedContentOrNull` with: +```kotlin +if (eventContent != null) { + TranslatableRichTextViewer(content = eventContent, ...) +} else { + TranslatableRichTextViewer(content = stringRes(R.string.could_not_decrypt_the_message), ...) +} +``` + +### Fix: Show error placeholder on Desktop + +```kotlin +decryptedContent = when (event) { + is PrivateDmEvent -> { + try { + event.decryptContent(account.signer) + } catch (_: Exception) { + null // Changed: signal failure instead of showing raw ciphertext + } + } + else -> event?.content +} + +// In display: +Text( + text = decryptedContent ?: "Could not decrypt the message", + style = if (decryptedContent == null) + MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic) + else + MaterialTheme.typography.bodyMedium, + color = if (decryptedContent == null) + MaterialTheme.colorScheme.onSurfaceVariant + else + MaterialTheme.colorScheme.onSurface, +) +``` + +### Research Insight: Cache invalidation after fix + +After deploying Fix 1, previously-failed messages are stuck in `DontTryAgain` cache state. Options: +- **App restart clears cache** (DecryptCache is in-memory only) — simplest +- Document that users should restart after update +- No code change needed for this + +--- + +## Phase Plan + +### Phase 1: Fix decrypt/encrypt response parsing (P0) +1. Update `Nip04DecryptResponse.parse()` — handle generic `BunkerResponse` with `response.result` +2. Update `Nip44DecryptResponse.parse()` — same pattern +3. Update `Nip04EncryptResponse.parse()` — same pattern for encrypt +4. Update `Nip44EncryptResponse.parse()` — same pattern +5. Write tests: generic `BunkerResponse(id, "Hello world", null)` → `Successful(DecryptionResult("Hello world"))` +6. Write tests: generic `BunkerResponse(id, null, null)` → `ReceivedButCouldNotPerform` + +### Phase 2: Fix timeout + safe retry (P0) +1. Increase `RemoteSignerManager.timeout` to `65_000` +2. Refactor `launchWaitAndParse`: build request once, retry = republish same event +3. Add `import kotlinx.coroutines.delay` +4. Write test: first `tryAndWait` times out, second succeeds (same request ID) +5. Write test: all attempts timeout → `TimedOut()` result +6. Write test: verify request built only once (same UUID across retries) + +### Phase 3: Desktop UI error display (P1) +1. In `ChatPane.kt`, change catch fallback from `event.content` to `null` +2. Display italic error placeholder when `decryptedContent == null` +3. Match Android's UX pattern (clear error state, not raw ciphertext) + +### Phase 4: Logging (P2) +1. Add `Log.d` in `newResponse()` when response arrives but no continuation found (late response) +2. Add `Log.w` in `launchWaitAndParse()` when timeout occurs (with request method) +3. Add `Log.d` on retry attempt + +### Phase 5: Test coverage +New tests: +- `ResponseParserDecryptFallbackTest` — generic BunkerResponse → Successful(DecryptionResult) +- `ResponseParserEncryptFallbackTest` — generic BunkerResponse → Successful(EncryptionResult) +- `ResponseParserNullResultTest` — BunkerResponse with null result → ReceivedButCouldNotPerform +- `RemoteSignerManagerRetryTest` — timeout then success (uses `runTest` + virtual time) +- `RemoteSignerManagerSameIdTest` — verify UUID stability across retries +- `RemoteSignerManagerMaxRetriesTest` — all timeout → TimedOut + +**Test patterns to use** (from kotlin-coroutines skill): +```kotlin +@Test +fun `retry succeeds on second attempt`() = runTest { + val fakeClient = EmptyNostrClient() + val manager = RemoteSignerManager( + timeout = 1000, // Short for tests + client = fakeClient, + signer = testSigner, + remoteKey = testRemoteKey, + relayList = setOf(testRelay), + ) + // ... simulate late response arrival +} +``` + +--- + +## Manual Testing Checklist + +- [ ] Login with bunker:// URI on Desktop +- [ ] Send a DM (requires sign + encrypt) +- [ ] Receive and read a DM (requires decrypt) +- [ ] Post a note (requires sign) +- [ ] Verify no "weird strings" in message views +- [ ] Verify error placeholder shown when bunker offline +- [ ] Verify timeout doesn't fire before 60s +- [ ] Restart app after fix to clear poisoned DecryptCache entries + +--- + +## File Change Summary + +| File | Change | Phase | +|------|--------|-------| +| `quartz/.../signer/Nip04DecryptResponse.kt` | Fallback to `response.result` | 1 | +| `quartz/.../signer/Nip44DecryptResponse.kt` | Fallback to `response.result` | 1 | +| `quartz/.../signer/Nip04EncryptResponse.kt` | Fallback to `response.result` | 1 | +| `quartz/.../signer/Nip44EncryptResponse.kt` | Fallback to `response.result` | 1 | +| `quartz/.../signer/RemoteSignerManager.kt` | Timeout 65s, retry with same request | 2 | +| `desktopApp/.../ui/chats/ChatPane.kt` | Error placeholder instead of raw ciphertext | 3 | +| `quartz/src/commonTest/.../ResponseParserFallbackTest.kt` | New test file | 5 | +| `quartz/src/commonTest/.../RemoteSignerManagerRetryTest.kt` | New test file | 5 | + +--- + +## Questions (Resolved) + +| Q | A | +|---|---| +| Android or Desktop? | Desktop (Jackson deserializer path) | +| Amber or Amethyst timeout msg? | Amber UI | +| Global or per-operation timeout? | Global, sync with Amber | +| Retry or just longer timeout? | Both: longer timeout + safe republish retry | +| Option A or B for decrypt? | A (parser handles generic BunkerResponse) | +| UUID per retry? | NO — build request once, reuse across attempts | +| Cache invalidation? | Not needed — app restart clears in-memory DecryptCache |