feat(clink): route profile + DVM single-invoice pays through the default source

The two remaining 'pay this bolt11' sites now honor the selected default
payment source instead of the binary NWC-or-intent check:
- AccountViewModel gains payInvoiceViaClinkDebit, the single-invoice debit-rail
  counterpart of sendZapPaymentRequestFor.
- DisplayLNAddress (profile LN-address pay) and DvmContentDiscoveryScreen (DVM
  invoice pay) dispatch on defaultPaymentSource(): CLINK debit -> 21002 round
  trip, NWC -> existing pay_invoice, none -> external wallet intent.

NWC-only users are unaffected. :amethyst compiles; the debit payout path stays
untested end-to-end.
This commit is contained in:
Claude
2026-06-09 22:31:26 +00:00
parent 002cd3cceb
commit 11891ace8c
3 changed files with 95 additions and 42 deletions
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
@@ -87,6 +88,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
@@ -2027,6 +2030,20 @@ class AccountViewModel(
onSent()
}
/**
* Pays a single BOLT-11 through a CLINK debit pointer (kind 21002) — the debit-rail
* counterpart of [sendZapPaymentRequestFor]. [onResult] receives the decrypted
* response (`isOk()` with optional preimage, or a GFY failure), or null on timeout.
* Untested end-to-end.
*/
fun payInvoiceViaClinkDebit(
pointer: NDebit,
bolt11: String,
onResult: (DebitResponse?) -> Unit,
) = launchSigner {
onResult(ClinkDebitPayer.payInvoice(account, pointer, bolt11))
}
fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag))
fun updateInteractiveStoryReadingState(
@@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -379,39 +380,57 @@ fun DvmPaymentActions(
if (invoice != null) {
val context = LocalContext.current
Button(onClick = {
if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) {
accountViewModel.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = null,
onSent = {
onStatusUpdate(nwcPaymentRequest)
},
onResponse = { response ->
when (val source = accountViewModel.account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
onStatusUpdate(nwcPaymentRequest)
accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response ->
onStatusUpdate(
if (response is PayInvoiceErrorResponse) {
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
)
} else {
if (response?.isOk() == true) {
thankYou
} else {
response?.error?.takeIf { it.isNotBlank() }
?: stringRes(context, R.string.clink_debit_no_response)
},
)
},
)
} else {
payViaIntent(
invoice,
context,
onPaid = {
onStatusUpdate(thankYou)
},
onError = {
onStatusUpdate(it)
},
)
}
}
is PaymentSource.Nwc -> {
accountViewModel.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = null,
onSent = {
onStatusUpdate(nwcPaymentRequest)
},
onResponse = { response ->
onStatusUpdate(
if (response is PayInvoiceErrorResponse) {
stringRes(
context,
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
)
} else {
thankYou
},
)
},
)
}
null -> {
payViaIntent(
invoice,
context,
onPaid = {
onStatusUpdate(thankYou)
},
onError = {
onStatusUpdate(it)
},
)
}
}
}) {
val amountInInvoice =
@@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText
@@ -109,22 +110,38 @@ fun DisplayLNAddress(
lud16,
user,
accountViewModel,
onSuccess = {
onSuccess = { invoice ->
zapExpanded = false
// pay directly
if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) {
accountViewModel.sendZapPaymentRequestFor(it, null) { response ->
if (response is PayInvoiceSuccessResponse) {
showInfoMessageDialog = stringRes(context, R.string.payment_successful)
} else if (response is PayInvoiceErrorResponse) {
showErrorMessageDialog =
response.error?.message
?: response.error?.code?.toString()
?: stringRes(context, R.string.error_parsing_error_message)
// pay directly through the selected default payment source
when (val source = accountViewModel.account.settings.defaultPaymentSource()) {
is PaymentSource.ClinkDebit -> {
accountViewModel.payInvoiceViaClinkDebit(source.wallet.pointer, invoice) { response ->
if (response?.isOk() == true) {
showInfoMessageDialog = stringRes(context, R.string.payment_successful)
} else {
showErrorMessageDialog =
response?.error?.takeIf { it.isNotBlank() }
?: stringRes(context, R.string.clink_debit_no_response)
}
}
}
} else {
payViaIntent(it, context, { zapExpanded = false }, { showErrorMessageDialog = it })
is PaymentSource.Nwc -> {
accountViewModel.sendZapPaymentRequestFor(invoice, null) { response ->
if (response is PayInvoiceSuccessResponse) {
showInfoMessageDialog = stringRes(context, R.string.payment_successful)
} else if (response is PayInvoiceErrorResponse) {
showErrorMessageDialog =
response.error?.message
?: response.error?.code?.toString()
?: stringRes(context, R.string.error_parsing_error_message)
}
}
}
null -> {
payViaIntent(invoice, context, { zapExpanded = false }, { showErrorMessageDialog = it })
}
}
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },