feat(desktop): support LNURL-pay and lightning addresses in send dialog

Rewrite SendDialog with sealed state machine that auto-detects input
type (BOLT11, LNURL bech32, lightning address). For LNURL/address:
resolves endpoint, shows amount form with min/max hint, optional
comment field, fetches invoice, then pays via NWC. Strips lightning:
URI prefix. Inline copiable errors with retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-23 15:40:19 +03:00
co-authored by Claude Opus 4.6
parent a5405fef34
commit 2b14b77acf
3 changed files with 582 additions and 62 deletions
@@ -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
@@ -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<String?>(null) }
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() }
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"
},
)
}
}
}