Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/main/java/to/bitkit/ext/Coroutines.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ package to.bitkit.ext
import kotlinx.coroutines.Job
import to.bitkit.utils.Logger

@Suppress("TooGenericExceptionCaught")
suspend inline fun <T> runSuspendCatching(crossinline block: suspend () -> T): Result<T> = try {
Result.success(block())
} catch (error: Throwable) {
Result.failure(error)
}

fun Job.logCompletion(name: String = "") = invokeOnCompletion { err ->
if (err != null) {
Logger.verbose("Coroutine '$name' error: ${err.message}")
Expand Down
28 changes: 23 additions & 5 deletions app/src/main/java/to/bitkit/repositories/LightningRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import com.synonym.bitkitcore.AddressType
import com.synonym.bitkitcore.ClosedChannelDetails
import com.synonym.bitkitcore.FeeRates
import com.synonym.bitkitcore.LightningInvoice
import com.synonym.bitkitcore.LnurlException
import com.synonym.bitkitcore.LnurlPayData
import com.synonym.bitkitcore.PreActivityMetadata
import com.synonym.bitkitcore.Scanner
import com.synonym.bitkitcore.createChannelRequestUrl
Expand Down Expand Up @@ -976,20 +978,20 @@ class LightningRepo @Inject constructor(
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
}

@Suppress("ForbiddenComment")
suspend fun fetchLnurlInvoice(
callbackUrl: String,
data: LnurlPayData,
amountMsats: ULong,
comment: String? = null,
): Result<LightningInvoice> {
return runCatching {
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr
val bolt11 = coreService.getLnurlInvoiceForPayData(data, amountMsats, comment)
val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice
return@runCatching decoded
}.recoverCatching {
throw it.toLnurlPayInvoiceError()
}.onFailure {
Logger.error(
"Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'",
"Failed to fetch LNURL invoice, uri: '${data.uri}', amountMsats: '$amountMsats', comment: '$comment'",
it,
context = TAG,
)
Expand Down Expand Up @@ -1657,11 +1659,27 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
class GetPaymentsError : AppError("It wasn't possible get the payments")
class SyncUnhealthyError : AppError("Wallet sync failed before send")
class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.")
sealed class ProbeError(message: String) : AppError(message) {
class NoProbeHandles : ProbeError("No probe handles returned")
class TimedOut : ProbeError("Probe timed out")
}

private fun Throwable.toLnurlPayInvoiceError(): Throwable {
val lnurlPayValidationError = generateSequence(this) { it.cause }
.firstOrNull { it.isLnurlPayValidationError() }

return if (lnurlPayValidationError != null) LnurlPayInvoiceMismatchError() else this
}

private fun Throwable.isLnurlPayValidationError(): Boolean = when (this) {
is LnurlException.InvalidAmount,
is LnurlException.AmountMismatch,
is LnurlException.MetadataMismatch -> true

else -> false
}

@Stable
data class LightningState(
val nodeId: String = "",
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.synonym.bitkitcore.IcJitEntry
import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult
import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview
import com.synonym.bitkitcore.LightningActivity
import com.synonym.bitkitcore.LnurlPayData
import com.synonym.bitkitcore.OnchainActivity
import com.synonym.bitkitcore.PaymentState
import com.synonym.bitkitcore.PaymentType
Expand Down Expand Up @@ -102,6 +103,7 @@ import kotlin.random.Random
import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails
import com.synonym.bitkitcore.TxInput as BitkitCoreTxInput
import com.synonym.bitkitcore.TxOutput as BitkitCoreTxOutput
import com.synonym.bitkitcore.getLnurlInvoiceForPayData as coreGetLnurlInvoiceForPayData
import com.synonym.bitkitcore.getTransactionDetails as getBitkitCoreTransactionDetails

// region Core
Expand Down Expand Up @@ -214,6 +216,14 @@ class CoreService @Inject constructor(
com.synonym.bitkitcore.decode(input)
}

suspend fun getLnurlInvoiceForPayData(
data: LnurlPayData,
amountMsats: ULong,
comment: String? = null,
): String = ServiceQueue.CORE.background {
coreGetLnurlInvoiceForPayData(data, amountMsats, comment)
}

companion object {
private const val TAG = "CoreService"
}
Expand Down
30 changes: 0 additions & 30 deletions app/src/main/java/to/bitkit/services/LnurlService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,30 +39,6 @@ class LnurlService @Inject constructor(
Logger.warn("Failed to request LNURL withdraw", it, context = TAG)
}

suspend fun fetchLnurlInvoice(
callbackUrl: String,
amountMsats: ULong,
comment: String? = null,
): Result<LnurlPayResponse> = runCatching {
Logger.debug("Fetching LNURL pay invoice from: $callbackUrl", context = TAG)

val response = client.get(callbackUrl) {
url {
parameters["amount"] = "$amountMsats"
comment?.takeIf { it.isNotBlank() }?.let {
parameters["comment"] = it
}
}
}
Logger.debug("Http call: $response", context = TAG)

if (!response.status.isSuccess()) {
throw HttpError("fetchLnurlInvoice error: '${response.status.description}'", response.status.value)
}

return@runCatching response.body<LnurlPayResponse>()
}

suspend fun requestLnurlChannel(url: String): Result<LnurlChannelResponse> = runCatching {
Logger.debug("Requesting LNURL channel request via: '$url'", context = TAG)

Expand Down Expand Up @@ -101,12 +77,6 @@ data class LnurlWithdrawResponse(
val balanceCheck: String? = null,
)

@Serializable
data class LnurlPayResponse(
val pr: String,
val routes: List<String>,
)

@Serializable
data class LnurlChannelResponse(
val status: String? = null,
Expand Down
16 changes: 11 additions & 5 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import to.bitkit.repositories.ConnectivityState
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.repositories.HealthRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
import to.bitkit.repositories.PaymentPendingException
import to.bitkit.repositories.PendingPaymentNotification
import to.bitkit.repositories.PendingPaymentRepo
Expand Down Expand Up @@ -2126,8 +2127,7 @@ class AppViewModel @Inject constructor(
lnurlPay != null -> {
QuickPayData.LnurlPay(
sats = amountSats,
callback = lnurlPay.callback,
amountMsats = lnurlPay.callbackAmountMsats(amountSats),
data = lnurlPay,
)
}

Expand Down Expand Up @@ -2250,15 +2250,16 @@ class AppViewModel @Inject constructor(
if (isLnurlPay) {
val amountMsats = lnurl.data.callbackAmountMsats(amount)
lightningRepo.fetchLnurlInvoice(
callbackUrl = lnurl.data.callback,
data = lnurl.data,
amountMsats = amountMsats,
comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() },
).onSuccess { invoice ->
_sendUiState.update {
it.copy(decodedInvoice = invoice)
}
}.onFailure {
toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch)))
val message = getLnurlInvoiceFetchErrorMessage(it)
toast(Exception(message))
hideSheet()
return
}
Expand Down Expand Up @@ -2359,6 +2360,11 @@ class AppViewModel @Inject constructor(
}
}

private fun getLnurlInvoiceFetchErrorMessage(error: Throwable): String = when (error) {
is LnurlPayInvoiceMismatchError -> context.getString(R.string.lightning__order_state__payment_canceled)
else -> context.getString(R.string.wallet__error_lnurl_invoice_fetch)
}

fun onConfirmWithdraw() {
_sendUiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
Expand Down Expand Up @@ -3304,7 +3310,7 @@ sealed interface QuickPayData {
data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData

@Stable
data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData
data class LnurlPay(override val sats: ULong, val data: LnurlPayData) : QuickPayData
}
// endregion

Expand Down
50 changes: 33 additions & 17 deletions app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,27 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import to.bitkit.di.BgDispatcher
import to.bitkit.ext.callbackAmountMsats
import to.bitkit.ext.getClipboardText
import to.bitkit.ext.maxSendableSat
import to.bitkit.ext.minSendableSat
import to.bitkit.ext.nowMs
import to.bitkit.ext.runSuspendCatching
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
import to.bitkit.models.BITCOIN_SYMBOL
import to.bitkit.models.Toast
import to.bitkit.models.satsToMsat
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
import to.bitkit.repositories.ProbeError
import to.bitkit.repositories.ProbeOutcome
import to.bitkit.services.CoreService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.utils.Logger
import javax.inject.Inject
import kotlin.time.Clock
import kotlin.time.ExperimentalTime

@OptIn(ExperimentalTime::class)
@HiltViewModel
class ProbingToolViewModel @Inject constructor(
@ApplicationContext private val context: Context,
Expand Down Expand Up @@ -77,6 +83,7 @@ class ProbingToolViewModel @Inject constructor(

viewModelScope.launch(bgDispatcher) {
_uiState.update { it.copy(isLoading = true, probeResult = null) }
val startTime = Clock.System.nowMs()

try {
val state = _uiState.value
Expand Down Expand Up @@ -145,7 +152,6 @@ class ProbingToolViewModel @Inject constructor(
}
}

val startTime = System.currentTimeMillis()
val dispatch = if (isNodeIdTarget) {
lightningRepo.sendProbeForNode(requireNotNull(nodeId), requireNotNull(amountSats))
} else {
Expand All @@ -159,6 +165,8 @@ class ProbingToolViewModel @Inject constructor(
.onFailure { handleProbeFailure(startTime, it) }
}
.onFailure { handleProbeFailure(startTime, it) }
} catch (error: LnurlPayInvoiceMismatchError) {
handleProbeFailure(startTime, error)
} finally {
_uiState.update { it.copy(isLoading = false) }
}
Expand Down Expand Up @@ -221,30 +229,38 @@ class ProbingToolViewModel @Inject constructor(
return lightning?.invoice?.amountSatoshis == 0uL
}

private suspend fun extractBolt11Invoice(input: String, amountSats: ULong?): String? = runCatching {
when (val decoded = coreService.decode(input)) {
is Scanner.Lightning -> decoded.invoice.bolt11
is Scanner.OnChain -> {
val lightningParam = decoded.invoice.params?.get("lightning") ?: return@runCatching null
(coreService.decode(lightningParam) as? Scanner.Lightning)?.invoice?.bolt11
}
private suspend fun extractBolt11Invoice(input: String, amountSats: ULong?): String? {
return runSuspendCatching {
when (val decoded = coreService.decode(input)) {
is Scanner.Lightning -> decoded.invoice.bolt11
is Scanner.OnChain -> {
val lightningParam = decoded.invoice.params?.get("lightning") ?: return@runSuspendCatching null
(coreService.decode(lightningParam) as? Scanner.Lightning)?.invoice?.bolt11
}

is Scanner.LnurlPay -> {
val amount = amountSats ?: return@runCatching null
lightningRepo.fetchLnurlInvoice(decoded.data.callback, satsToMsat(amount)).getOrThrow().bolt11
}
is Scanner.LnurlPay -> {
val amount = amountSats ?: return@runSuspendCatching null
lightningRepo.fetchLnurlInvoice(
data = decoded.data,
amountMsats = decoded.data.callbackAmountMsats(amount),
).getOrThrow().bolt11
}

else -> null
else -> null
}
}.getOrElse {
if (it is LnurlPayInvoiceMismatchError) throw it
null
}
}.getOrNull()
}

private suspend fun handleProbeOutcome(
startTime: Long,
outcome: ProbeOutcome,
invoice: String?,
amountSats: ULong?,
) {
val durationMs = System.currentTimeMillis() - startTime
val durationMs = Clock.System.nowMs() - startTime
when (outcome) {
is ProbeOutcome.Success -> {
Logger.info(
Expand Down Expand Up @@ -288,7 +304,7 @@ class ProbingToolViewModel @Inject constructor(
}

private suspend fun handleProbeFailure(startTime: Long, error: Throwable) {
val durationMs = System.currentTimeMillis() - startTime
val durationMs = Clock.System.nowMs() - startTime
Logger.error("Failed probe in '${durationMs}ms'", error, context = TAG)

val friendlyMessage = getFriendlyErrorMessage(error)
Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.PaymentId
import to.bitkit.ext.WatchResult
import to.bitkit.ext.callbackAmountMsats
import to.bitkit.ext.toUserMessage
import to.bitkit.ext.watchUntil
import to.bitkit.repositories.LightningRepo
Expand Down Expand Up @@ -48,8 +49,8 @@ class QuickPayViewModel @Inject constructor(
is QuickPayData.LnurlPay -> {
Logger.info("QuickPay: fetching LNURL Pay invoice from callback")
val invoice = lightningRepo.fetchLnurlInvoice(
callbackUrl = data.callback,
amountMsats = data.amountMsats,
data = data.data,
amountMsats = data.data.callbackAmountMsats(data.sats),
)
.getOrElse { error ->
_uiState.update {
Expand Down
Loading
Loading