diff --git a/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md new file mode 100644 index 0000000000..def49f9f7e --- /dev/null +++ b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md @@ -0,0 +1,88 @@ +# Brainstorm: Send Dialog LNURL-Pay Support + +**Date:** 2026-05-23 +**Status:** Ready for planning + +## What We're Building + +Extend the desktop wallet Send dialog to accept LNURL-pay strings and lightning addresses in addition to BOLT11 invoices. The dialog auto-detects input type, resolves LNURL endpoints, and presents an amount/comment form before fetching the final BOLT11 invoice and paying via NWC. + +## Why + +Users commonly receive payment requests as lightning addresses (`user@domain`) or LNURL bech32 strings, not just raw BOLT11 invoices. The current send dialog rejects these with a confusing "Unknown chain url" error from the NWC wallet. + +## Input Types + +| Format | Example | Detection | +|--------|---------|-----------| +| BOLT11 | `lnbc210n1p4pr...` | Starts with `lnbc` (regex in `LnInvoiceUtil`) | +| LNURL | `lnurl1dp68gurn...` | Starts with `lnurl` (decode via `Lud06.toLnUrlp`) | +| Lightning address | `user@domain.com` | Contains `@`, split on `@` | + +## Dialog Flow + +### State Machine + +``` +Input -> detecting type... + | + |- BOLT11 detected -> [Pay Invoice] (current flow, no change) + | + |- LNURL/address detected -> resolving endpoint (spinner)... + | + |- Fixed amount -> show amount (read-only), optional comment -> [Pay] + |- Variable amount -> show amount field with min/max hint, optional comment -> [Pay] + |- Resolution error -> inline error (copiable) + | + [Pay] -> fetching invoice (spinner)... + | + |- Got BOLT11 -> paying via NWC (spinner)... + | |- Success -> close dialog, snackbar + | |- Error -> inline error, button resets to [Pay] + |- Fetch error -> inline error, button resets to [Pay] +``` + +### UI States + +1. **Input** — text field + paste button (current) +2. **Resolving** — spinner below input, input disabled +3. **Amount** — shows endpoint info + amount field (with min/max label) + optional comment field +4. **Paying** — button shows "Paying...", fields disabled +5. **Error** — inline red text (copiable via SelectionContainer), button resets + +### Amount Input + +- Label: `"Amount (1 - 500,000 sats)"` — populated from `minSendable`/`maxSendable` (converted from msats) +- If fixed amount (`minSendable == maxSendable`): show read-only, prepopulated +- Digits only, validate against range on submit +- Comment field: only visible if `commentAllowed > 0` from endpoint response + +## Key Decisions + +- **Auto-detect on paste/type** — no explicit "Resolve" button; detect as user types/pastes +- **Single dialog, multi-step** — no separate dialogs for LNURL vs BOLT11 +- **Reuse `LightningAddressResolver`** — already handles LNURL endpoint fetch + invoice callback +- **Comment support** — show optional comment field when endpoint allows it +- **Amount in sats** — convert msats from LNURL spec to sats for display +- **Error display** — inline, copiable, same pattern as current SendDialog + +## Reusable Code + +| Component | Location | Notes | +|-----------|----------|-------| +| `Lud06.toLnUrlp()` | quartz | Decodes LNURL bech32 to URL | +| `LnInvoiceUtil.findInvoice()` | quartz | Detects BOLT11 pattern | +| `LightningAddressResolver.assembleUrl()` | commons | `user@domain` -> endpoint URL | +| `LightningAddressResolver.fetchInvoice()` | commons | Full LNURL-pay flow (fetch endpoint, get invoice) | +| `NwcPaymentHandler.payInvoice()` | desktopApp | Pay BOLT11 via NWC | + +## Resolved Questions + +- **Input types**: BOLT11 + LNURL + lightning address (all three) +- **Amount UX**: Free text field with min/max hint from endpoint +- **Comments**: Yes, show comment field if `commentAllowed > 0` +- **Flow**: Auto-detect + resolve inline (single dialog, multi-step) + +## Open Questions + +None — all questions resolved during brainstorm. diff --git a/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-plan.md b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-plan.md new file mode 100644 index 0000000000..4a3e2c8ba2 --- /dev/null +++ b/desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-plan.md @@ -0,0 +1,183 @@ +--- +title: "feat: Support LNURL-pay and lightning addresses in send dialog" +type: feat +status: active +date: 2026-05-23 +origin: desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md +deepened: 2026-05-23 +--- + +# feat: Support LNURL-pay and lightning addresses in send dialog + +## Enhancement Summary + +**Deepened on:** 2026-05-23 +**Research agents:** LNURL edge cases, Compose state machine patterns + +### Key Improvements from Research +1. `LightningAddressResolver` doesn't parse `minSendable`/`maxSendable`/`commentAllowed` — must fetch and parse endpoint JSON directly in dialog +2. Use `ImportFollowListDialog` sealed class state machine pattern (proven in codebase) +3. Strip `lightning:` URI prefix from pasted input +4. `fetchInvoice()` can be called with just amount (no zap request) — reuse for final invoice fetch + +## Overview + +Extend the desktop SendDialog to accept LNURL bech32 strings and lightning addresses (`user@domain`) in addition to BOLT11 invoices. Auto-detect input type, resolve LNURL endpoints, and present an amount/comment form before fetching the final BOLT11 invoice and paying via NWC. + +## Problem + +User pastes an LNURL or lightning address into the send dialog and gets "Unknown chain url: invalid token" error because the dialog only supports BOLT11 invoices. + +## Proposed Solution + +Single dialog, multi-step flow with auto-detection (see brainstorm). + +### Input Detection + +```kotlin +fun classifyInput(input: String): PaymentInput { + val trimmed = input.trim() + .removePrefix("lightning:") // Strip lightning: URI prefix + .trim() + // 1. BOLT11: starts with lnbc + LnInvoiceUtil.findInvoice(trimmed)?.let { return PaymentInput.Bolt11(it) } + // 2. LNURL bech32: starts with lnurl + if (trimmed.lowercase().startsWith("lnurl")) { + Lud06().toLnUrlp(trimmed)?.let { return PaymentInput.LnurlPay(it) } + } + // 3. Lightning address: user@domain + if (trimmed.contains("@") && trimmed.contains(".")) { + val parts = trimmed.split("@") + if (parts.size == 2) return PaymentInput.LnurlPay("https://${parts[1]}/.well-known/lnurlp/${parts[0]}") + } + return PaymentInput.Unknown +} +``` + +### Dialog State Machine + +Follow `ImportFollowListDialog` pattern — sealed class with `LaunchedEffect` auto-transitions. + +```kotlin +sealed class SendState { + data object Idle : SendState() + data class Resolving(val url: String) : SendState() + data class NeedsAmount( + val lnAddress: String, // original input for fetchInvoice + val callback: String, + val minSats: Long, + val maxSats: Long, + val commentAllowed: Int, + ) : SendState() + data class ReadyToPay(val bolt11: String) : SendState() + data class FetchingInvoice(val lnAddress: String, val amountSats: Long, val comment: String) : SendState() + data object Paying : SendState() + data class Error(val message: String) : SendState() +} +``` + +### State Transitions via LaunchedEffect + +```kotlin +// Auto-resolve LNURL endpoint when entering Resolving state +LaunchedEffect(sendState) { + val state = sendState + if (state is SendState.Resolving) { + // Fetch LNURL-pay JSON using OkHttp directly + val json = fetchLnurlPayEndpoint(state.url) + // Parse: callback, minSendable, maxSendable, commentAllowed + // Transition to NeedsAmount or Error + } +} + +// Auto-fetch invoice when entering FetchingInvoice state +LaunchedEffect(sendState) { + val state = sendState + if (state is SendState.FetchingInvoice) { + val resolver = LightningAddressResolver(DesktopHttpClient.currentClient()) + val result = resolver.fetchInvoice( + lnAddress = state.lnAddress, + milliSats = state.amountSats * 1000, + message = state.comment, + ) + // Transition to ReadyToPay or Error + } +} +``` + +### LNURL Endpoint Parsing (new logic in dialog) + +```kotlin +// Fetch and parse LNURL-pay endpoint JSON +// Fields needed: callback, minSendable, maxSendable, commentAllowed +val lnurlp = mapper.readTree(responseBody) +val callback = lnurlp.get("callback")?.asText() +val minSendable = lnurlp.get("minSendable")?.asLong() ?: 1000 // msats +val maxSendable = lnurlp.get("maxSendable")?.asLong() ?: 100_000_000 // msats +val commentAllowed = lnurlp.get("commentAllowed")?.asInt() ?: 0 +val isFixed = minSendable == maxSendable +``` + +### UI Layout per State + +| State | Shows | +|-------|-------| +| Idle | Input field ("Payment request or lightning address"), paste button | +| Resolving | Input (disabled), spinner below | +| NeedsAmount | Input (disabled, shows address), amount field w/ "Amount (min - max sats)", comment (if allowed), Pay | +| ReadyToPay | Input (disabled), Pay button | +| FetchingInvoice | Fields disabled, spinner | +| Paying | All disabled, "Paying..." button | +| Error | Inline red copiable text, Retry button resets to appropriate prior state | + +### Edge Cases + +- **`lightning:lnbc...`** prefix: strip before classification +- **Fixed amount** (`minSendable == maxSendable`): prepopulate, make read-only +- **Amount out of range**: validate client-side before fetching invoice, show inline error +- **Endpoint timeout**: 15s timeout on LNURL fetch, show error +- **Invoice amount mismatch**: `fetchInvoice()` already validates this (line 172) +- **`commentAllowed = 0`**: hide comment field entirely +- **Invalid LNURL bech32**: `Lud06().toLnUrlp()` returns null → stays Unknown + +## Files to Modify + +| File | Change | +|------|--------| +| `WalletColumnScreen.kt` (SendDialog) | Rewrite to multi-step flow with sealed state machine | + +No new files needed. All LNURL utilities already exist in quartz/commons. + +## Reusable Code + +| Component | Location | Usage | +|-----------|----------|-------| +| `LnInvoiceUtil.findInvoice()` | quartz | Detect BOLT11 | +| `Lud06().toLnUrlp()` | quartz | Decode LNURL bech32 | +| `LightningAddressResolver.fetchInvoice()` | commons | Fetch invoice with amount (no zap request) | +| `DesktopHttpClient.currentClient()` | desktopApp | OkHttpClient | +| `NwcPaymentHandler.payInvoice()` | desktopApp | Pay BOLT11 via NWC | +| `jacksonObjectMapper()` | already imported | Parse LNURL endpoint JSON | + +## Acceptance Criteria + +- [ ] Pasting a BOLT11 invoice works as before (no regression) +- [ ] Pasting `lightning:lnbc...` works (prefix stripped) +- [ ] Pasting an LNURL bech32 string resolves and shows amount form +- [ ] Pasting a lightning address (user@domain) resolves and shows amount form +- [ ] Fixed-amount LNURL prepopulates amount (read-only) +- [ ] Variable-amount LNURL shows input with min/max hint +- [ ] Comment field appears when endpoint `commentAllowed > 0` +- [ ] Amount validated against min/max before fetching invoice +- [ ] Errors shown inline (copiable), button resets for retry +- [ ] "Paste from Clipboard" works for all input types +- [ ] Label changed from "BOLT11 Invoice" to "Payment request or lightning address" + +## Sources + +- **Origin brainstorm:** desktopApp/plans/2026-05-23-feat-send-dialog-lnurl-pay-brainstorm.md +- **State machine pattern:** desktopApp/.../ui/ImportFollowListDialog.kt (sealed class + LaunchedEffect) +- `LightningAddressResolver`: commons/src/jvmAndroid/.../LightningAddressResolver.kt:47-234 +- `Lud06.toLnUrlp()`: quartz/src/commonMain/.../lightning/Lud06.kt:51-58 +- `LnInvoiceUtil.findInvoice()`: quartz/src/commonMain/.../lightning/LnInvoiceUtil.kt:302-307 +- Current `SendDialog`: desktopApp/.../ui/wallet/WalletColumnScreen.kt:457-584 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index be6237263d..7b11e0e50e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -63,15 +63,19 @@ 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 @@ -453,6 +457,73 @@ private fun ConnectWalletDialog( ) } +/** + * 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, @@ -460,12 +531,104 @@ private fun SendDialog( paymentHandler: NwcPaymentHandler, nwcConnection: Nip47URINorm, ) { - var invoice by remember { mutableStateOf("") } - var isSending by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } + var input by remember { mutableStateOf("") } + var sendState by remember { mutableStateOf(SendState.Idle) } + var amount by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } val scope = rememberCoroutineScope() + val mapper = remember { jacksonObjectMapper() } - Dialog(onDismissRequest = { if (!isSending) onDismiss() }) { + 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), @@ -478,7 +641,7 @@ private fun SendDialog( style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) - IconButton(onClick = { if (!isSending) onDismiss() }) { + IconButton(onClick = { if (!isLoading) onDismiss() }) { Icon( MaterialSymbols.Close, contentDescription = "Close", @@ -489,43 +652,101 @@ private fun SendDialog( Spacer(Modifier.height(16.dp)) + // Input field OutlinedTextField( - value = invoice, + value = input, onValueChange = { - invoice = it - errorMessage = null + input = it + sendState = SendState.Idle }, - label = { Text("BOLT11 Invoice") }, - placeholder = { Text("lnbc...") }, + label = { Text("Invoice, LNURL, or lightning address") }, + placeholder = { Text("lnbc..., lnurl1..., or user@domain") }, modifier = Modifier.fillMaxWidth(), singleLine = false, - maxLines = 6, + maxLines = 4, + enabled = sendState is SendState.Idle || sendState is SendState.Error, ) Spacer(Modifier.height(8.dp)) - OutlinedButton(onClick = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - val text = - try { - clipboard.getData(DataFlavor.stringFlavor) as? String - } catch (_: Exception) { - null + 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) } - if (text != null) { - invoice = text - errorMessage = null + }) { + Text("Paste from Clipboard") } - }) { - Text("Paste from Clipboard") } - // Inline error — copiable - if (errorMessage != null) { + // 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 = errorMessage!!, + text = errorState.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.fillMaxWidth(), @@ -535,47 +756,75 @@ private fun SendDialog( Spacer(Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onDismiss, enabled = !isSending) { - Text("Cancel") + // 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 } - Spacer(Modifier.width(8.dp)) - Button( - onClick = { - isSending = true - errorMessage = null - scope.launch { - val result = paymentHandler.payInvoice(bolt11 = invoice, nwcConnection = nwcConnection) - when (result) { - is NwcPaymentHandler.PaymentResult.Success -> onSuccess() - is NwcPaymentHandler.PaymentResult.Error -> { - errorMessage = result.message - isSending = false - } - is NwcPaymentHandler.PaymentResult.Timeout -> { - errorMessage = "Payment timed out" - isSending = 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) + } } } } - }, - enabled = invoice.isNotBlank() && !isSending, - ) { - if (isSending) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Sending...") - } else { - Text("Pay Invoice") + 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" + }, + ) } } }