mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
+6
-7
@@ -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()
|
||||
}
|
||||
|
||||
+20
@@ -144,6 +144,26 @@ open class RelayConnectionManager(
|
||||
publish(event, connected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a relay to appear in connectedRelays, adding it if needed.
|
||||
* Returns true if connected within the timeout, false otherwise.
|
||||
*/
|
||||
suspend fun ensureRelayConnected(
|
||||
relay: NormalizedRelayUrl,
|
||||
timeoutMs: Long = 10_000,
|
||||
): Boolean {
|
||||
if (relay in connectedRelays.value) return true
|
||||
if (relay !in availableRelays.value) {
|
||||
updateRelayStatus(relay) { it.copy(connected = false, error = null) }
|
||||
}
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (relay in connectedRelays.value) return true
|
||||
delay(200)
|
||||
}
|
||||
return relay in connectedRelays.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event to a specific relay (for NWC).
|
||||
* Adds the relay if not already in the list.
|
||||
|
||||
+178
@@ -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 --
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+12
@@ -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(
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+980
@@ -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)
|
||||
+203
@@ -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)
|
||||
}
|
||||
}
|
||||
+192
@@ -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 |
|
||||
Reference in New Issue
Block a user