Merge pull request #3038 from nrobi144/feat/desktop-wallet-zapping

feat(desktop): wallet zapping, LNURL-pay send, QR receive, and session persistence
This commit is contained in:
Vitor Pamplona
2026-05-23 12:05:56 -04:00
committed by GitHub
24 changed files with 4524 additions and 21 deletions
@@ -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
@@ -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/<pubkey8>/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<Event>(
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/<pubkey8>/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<ContactListEvent>(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<MetadataEvent>(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<Event>(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<Boolean> = _diskWarning
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _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
@@ -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?
@@ -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<DesktopRelayConnectionManager>(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<NwcPaymentHandler.PaymentResult.Success>(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<Long?>(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<GetBalanceMethod>(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<GetBalanceSuccessResponse>(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)
@@ -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<WalletInfo>, StateFlow<SendState>, etc.)
- `Account` dependency is already platform-agnostic
```kotlin
class SharedWalletViewModel(
val account: Account,
val scope: CoroutineScope,
) {
val walletInfoList: StateFlow<List<WalletInfo>>
val sendState: StateFlow<WalletSendState>
val receiveState: StateFlow<WalletReceiveState>
val transactions: StateFlow<List<Transaction>>
val selectedFilter: StateFlow<TransactionFilter>
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?
@@ -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** | | | | |
@@ -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 |
@@ -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.
@@ -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
@@ -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<AccountState.LoggedIn>
// After: loginWithKey saves inline
suspend fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
// ... 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.
@@ -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()
}
@@ -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.
@@ -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 <T> 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)
}
}
}
@@ -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<AccountState.LoggedIn?>(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()
},
)
}
@@ -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> =
DeckColumnType.Settings,
DeckColumnType.Relays,
DeckColumnType.Chess,
DeckColumnType.Wallet,
)
// -- Tabs --
@@ -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
@@ -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(
@@ -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"
@@ -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)
@@ -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<Long?>(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<String?>(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>(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<String?>(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)
@@ -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<NwcPaymentHandler.PaymentResult.Success>(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<NwcPaymentHandler.PaymentResult.Error>(result)
assertEquals("Insufficient balance", result.message)
}
@Test
fun `PaymentResult Timeout is singleton`() {
val result = NwcPaymentHandler.PaymentResult.Timeout
assertIs<NwcPaymentHandler.PaymentResult.Timeout>(result)
}
// -- BalanceResult types --
@Test
fun `BalanceResult Success holds msats`() {
val result = NwcPaymentHandler.BalanceResult.Success(125_000)
assertIs<NwcPaymentHandler.BalanceResult.Success>(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>(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>(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)
}
}
@@ -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<PayInvoiceMethod>(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<GetBalanceMethod>(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<GetBalanceSuccessResponse>(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<MakeInvoiceMethod>(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<MakeInvoiceSuccessResponse>(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<PayInvoiceSuccessResponse>(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<GetBalanceMethod>(decrypted)
}
}
@@ -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<Note>()
}
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<HexKey>, 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<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val listener = object : SubscriptionListener {
override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
onEvent?.invoke(event, relay)
}
override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
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`
@@ -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<DecryptionResult> =
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<T : IResult>(
val message: String? = null,
) : RequestAddressed<T>
```
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<NormalizedRelayUrl>,
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
suspend fun newResponse(responseEvent: NostrConnectEvent) {
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
}
suspend fun <T : IResult> launchWaitAndParse(
bunkerRequestBuilder: () -> BunkerRequest,
parser: (response: BunkerResponse) -> SignerResult.RequestAddressed<T>,
maxRetries: Int = 1,
): SignerResult.RequestAddressed<T> {
// 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 |