Skip to content

Commit a960a34

Browse files
committed
fix: use core LNURL-pay validation
1 parent 5bd5bee commit a960a34

10 files changed

Lines changed: 162 additions & 60 deletions

File tree

app/src/main/java/to/bitkit/ext/Coroutines.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ package to.bitkit.ext
33
import kotlinx.coroutines.Job
44
import to.bitkit.utils.Logger
55

6+
@Suppress("TooGenericExceptionCaught")
7+
suspend inline fun <T> runSuspendCatching(crossinline block: suspend () -> T): Result<T> = try {
8+
Result.success(block())
9+
} catch (error: Throwable) {
10+
Result.failure(error)
11+
}
12+
613
fun Job.logCompletion(name: String = "") = invokeOnCompletion { err ->
714
if (err != null) {
815
Logger.verbose("Coroutine '$name' error: ${err.message}")

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import com.synonym.bitkitcore.AddressType
66
import com.synonym.bitkitcore.ClosedChannelDetails
77
import com.synonym.bitkitcore.FeeRates
88
import com.synonym.bitkitcore.LightningInvoice
9+
import com.synonym.bitkitcore.LnurlException
10+
import com.synonym.bitkitcore.LnurlPayData
911
import com.synonym.bitkitcore.PreActivityMetadata
1012
import com.synonym.bitkitcore.Scanner
1113
import com.synonym.bitkitcore.createChannelRequestUrl
@@ -976,20 +978,20 @@ class LightningRepo @Inject constructor(
976978
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
977979
}
978980

979-
@Suppress("ForbiddenComment")
980981
suspend fun fetchLnurlInvoice(
981-
callbackUrl: String,
982+
data: LnurlPayData,
982983
amountMsats: ULong,
983984
comment: String? = null,
984985
): Result<LightningInvoice> {
985986
return runCatching {
986-
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
987-
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr
987+
val bolt11 = coreService.getLnurlInvoiceForPayData(data, amountMsats, comment)
988988
val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice
989989
return@runCatching decoded
990+
}.recoverCatching {
991+
throw it.toLnurlPayInvoiceError()
990992
}.onFailure {
991993
Logger.error(
992-
"Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'",
994+
"Failed to fetch LNURL invoice, uri: '${data.uri}', amountMsats: '$amountMsats', comment: '$comment'",
993995
it,
994996
context = TAG,
995997
)
@@ -1657,11 +1659,27 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
16571659
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
16581660
class GetPaymentsError : AppError("It wasn't possible get the payments")
16591661
class SyncUnhealthyError : AppError("Wallet sync failed before send")
1662+
class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.")
16601663
sealed class ProbeError(message: String) : AppError(message) {
16611664
class NoProbeHandles : ProbeError("No probe handles returned")
16621665
class TimedOut : ProbeError("Probe timed out")
16631666
}
16641667

1668+
private fun Throwable.toLnurlPayInvoiceError(): Throwable {
1669+
val lnurlPayValidationError = generateSequence(this) { it.cause }
1670+
.firstOrNull { it.isLnurlPayValidationError() }
1671+
1672+
return if (lnurlPayValidationError != null) LnurlPayInvoiceMismatchError() else this
1673+
}
1674+
1675+
private fun Throwable.isLnurlPayValidationError(): Boolean = when (this) {
1676+
is LnurlException.InvalidAmount,
1677+
is LnurlException.AmountMismatch,
1678+
is LnurlException.MetadataMismatch -> true
1679+
1680+
else -> false
1681+
}
1682+
16651683
@Stable
16661684
data class LightningState(
16671685
val nodeId: String = "",

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.synonym.bitkitcore.IcJitEntry
2020
import com.synonym.bitkitcore.LegacyRnCloseRecoveryScanResult
2121
import com.synonym.bitkitcore.LegacyRnCloseRecoverySweepPreview
2222
import com.synonym.bitkitcore.LightningActivity
23+
import com.synonym.bitkitcore.LnurlPayData
2324
import com.synonym.bitkitcore.OnchainActivity
2425
import com.synonym.bitkitcore.PaymentState
2526
import com.synonym.bitkitcore.PaymentType
@@ -102,6 +103,7 @@ import kotlin.random.Random
102103
import com.synonym.bitkitcore.TransactionDetails as BitkitCoreTransactionDetails
103104
import com.synonym.bitkitcore.TxInput as BitkitCoreTxInput
104105
import com.synonym.bitkitcore.TxOutput as BitkitCoreTxOutput
106+
import com.synonym.bitkitcore.getLnurlInvoiceForPayData as coreGetLnurlInvoiceForPayData
105107
import com.synonym.bitkitcore.getTransactionDetails as getBitkitCoreTransactionDetails
106108

107109
// region Core
@@ -214,6 +216,14 @@ class CoreService @Inject constructor(
214216
com.synonym.bitkitcore.decode(input)
215217
}
216218

219+
suspend fun getLnurlInvoiceForPayData(
220+
data: LnurlPayData,
221+
amountMsats: ULong,
222+
comment: String? = null,
223+
): String = ServiceQueue.CORE.background {
224+
coreGetLnurlInvoiceForPayData(data, amountMsats, comment)
225+
}
226+
217227
companion object {
218228
private const val TAG = "CoreService"
219229
}

app/src/main/java/to/bitkit/services/LnurlService.kt

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,30 +39,6 @@ class LnurlService @Inject constructor(
3939
Logger.warn("Failed to request LNURL withdraw", it, context = TAG)
4040
}
4141

42-
suspend fun fetchLnurlInvoice(
43-
callbackUrl: String,
44-
amountMsats: ULong,
45-
comment: String? = null,
46-
): Result<LnurlPayResponse> = runCatching {
47-
Logger.debug("Fetching LNURL pay invoice from: $callbackUrl", context = TAG)
48-
49-
val response = client.get(callbackUrl) {
50-
url {
51-
parameters["amount"] = "$amountMsats"
52-
comment?.takeIf { it.isNotBlank() }?.let {
53-
parameters["comment"] = it
54-
}
55-
}
56-
}
57-
Logger.debug("Http call: $response", context = TAG)
58-
59-
if (!response.status.isSuccess()) {
60-
throw HttpError("fetchLnurlInvoice error: '${response.status.description}'", response.status.value)
61-
}
62-
63-
return@runCatching response.body<LnurlPayResponse>()
64-
}
65-
6642
suspend fun requestLnurlChannel(url: String): Result<LnurlChannelResponse> = runCatching {
6743
Logger.debug("Requesting LNURL channel request via: '$url'", context = TAG)
6844

@@ -101,12 +77,6 @@ data class LnurlWithdrawResponse(
10177
val balanceCheck: String? = null,
10278
)
10379

104-
@Serializable
105-
data class LnurlPayResponse(
106-
val pr: String,
107-
val routes: List<String>,
108-
)
109-
11080
@Serializable
11181
data class LnurlChannelResponse(
11282
val status: String? = null,

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ import to.bitkit.repositories.ConnectivityState
124124
import to.bitkit.repositories.CurrencyRepo
125125
import to.bitkit.repositories.HealthRepo
126126
import to.bitkit.repositories.LightningRepo
127+
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
127128
import to.bitkit.repositories.PaymentPendingException
128129
import to.bitkit.repositories.PendingPaymentNotification
129130
import to.bitkit.repositories.PendingPaymentRepo
@@ -2126,8 +2127,7 @@ class AppViewModel @Inject constructor(
21262127
lnurlPay != null -> {
21272128
QuickPayData.LnurlPay(
21282129
sats = amountSats,
2129-
callback = lnurlPay.callback,
2130-
amountMsats = lnurlPay.callbackAmountMsats(amountSats),
2130+
data = lnurlPay,
21312131
)
21322132
}
21332133

@@ -2250,15 +2250,16 @@ class AppViewModel @Inject constructor(
22502250
if (isLnurlPay) {
22512251
val amountMsats = lnurl.data.callbackAmountMsats(amount)
22522252
lightningRepo.fetchLnurlInvoice(
2253-
callbackUrl = lnurl.data.callback,
2253+
data = lnurl.data,
22542254
amountMsats = amountMsats,
22552255
comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() },
22562256
).onSuccess { invoice ->
22572257
_sendUiState.update {
22582258
it.copy(decodedInvoice = invoice)
22592259
}
22602260
}.onFailure {
2261-
toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch)))
2261+
val message = getLnurlInvoiceFetchErrorMessage(it)
2262+
toast(Exception(message))
22622263
hideSheet()
22632264
return
22642265
}
@@ -2359,6 +2360,11 @@ class AppViewModel @Inject constructor(
23592360
}
23602361
}
23612362

2363+
private fun getLnurlInvoiceFetchErrorMessage(error: Throwable): String = when (error) {
2364+
is LnurlPayInvoiceMismatchError -> context.getString(R.string.lightning__order_state__payment_canceled)
2365+
else -> context.getString(R.string.wallet__error_lnurl_invoice_fetch)
2366+
}
2367+
23622368
fun onConfirmWithdraw() {
23632369
_sendUiState.update { it.copy(isLoading = true) }
23642370
viewModelScope.launch {
@@ -3304,7 +3310,7 @@ sealed interface QuickPayData {
33043310
data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData
33053311

33063312
@Stable
3307-
data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData
3313+
data class LnurlPay(override val sats: ULong, val data: LnurlPayData) : QuickPayData
33083314
}
33093315
// endregion
33103316

app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,27 @@ import kotlinx.coroutines.flow.asStateFlow
1313
import kotlinx.coroutines.flow.update
1414
import kotlinx.coroutines.launch
1515
import to.bitkit.di.BgDispatcher
16+
import to.bitkit.ext.callbackAmountMsats
1617
import to.bitkit.ext.getClipboardText
1718
import to.bitkit.ext.maxSendableSat
1819
import to.bitkit.ext.minSendableSat
20+
import to.bitkit.ext.nowMs
21+
import to.bitkit.ext.runSuspendCatching
1922
import to.bitkit.ext.totalNextOutboundHtlcLimitSats
2023
import to.bitkit.models.BITCOIN_SYMBOL
2124
import to.bitkit.models.Toast
22-
import to.bitkit.models.satsToMsat
2325
import to.bitkit.repositories.LightningRepo
26+
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
2427
import to.bitkit.repositories.ProbeError
2528
import to.bitkit.repositories.ProbeOutcome
2629
import to.bitkit.services.CoreService
2730
import to.bitkit.ui.shared.toast.ToastEventBus
2831
import to.bitkit.utils.Logger
2932
import javax.inject.Inject
33+
import kotlin.time.Clock
34+
import kotlin.time.ExperimentalTime
3035

36+
@OptIn(ExperimentalTime::class)
3137
@HiltViewModel
3238
class ProbingToolViewModel @Inject constructor(
3339
@ApplicationContext private val context: Context,
@@ -77,6 +83,7 @@ class ProbingToolViewModel @Inject constructor(
7783

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

8188
try {
8289
val state = _uiState.value
@@ -145,7 +152,6 @@ class ProbingToolViewModel @Inject constructor(
145152
}
146153
}
147154

148-
val startTime = System.currentTimeMillis()
149155
val dispatch = if (isNodeIdTarget) {
150156
lightningRepo.sendProbeForNode(requireNotNull(nodeId), requireNotNull(amountSats))
151157
} else {
@@ -159,6 +165,8 @@ class ProbingToolViewModel @Inject constructor(
159165
.onFailure { handleProbeFailure(startTime, it) }
160166
}
161167
.onFailure { handleProbeFailure(startTime, it) }
168+
} catch (error: LnurlPayInvoiceMismatchError) {
169+
handleProbeFailure(startTime, error)
162170
} finally {
163171
_uiState.update { it.copy(isLoading = false) }
164172
}
@@ -221,30 +229,38 @@ class ProbingToolViewModel @Inject constructor(
221229
return lightning?.invoice?.amountSatoshis == 0uL
222230
}
223231

224-
private suspend fun extractBolt11Invoice(input: String, amountSats: ULong?): String? = runCatching {
225-
when (val decoded = coreService.decode(input)) {
226-
is Scanner.Lightning -> decoded.invoice.bolt11
227-
is Scanner.OnChain -> {
228-
val lightningParam = decoded.invoice.params?.get("lightning") ?: return@runCatching null
229-
(coreService.decode(lightningParam) as? Scanner.Lightning)?.invoice?.bolt11
230-
}
232+
private suspend fun extractBolt11Invoice(input: String, amountSats: ULong?): String? {
233+
return runSuspendCatching {
234+
when (val decoded = coreService.decode(input)) {
235+
is Scanner.Lightning -> decoded.invoice.bolt11
236+
is Scanner.OnChain -> {
237+
val lightningParam = decoded.invoice.params?.get("lightning") ?: return@runSuspendCatching null
238+
(coreService.decode(lightningParam) as? Scanner.Lightning)?.invoice?.bolt11
239+
}
231240

232-
is Scanner.LnurlPay -> {
233-
val amount = amountSats ?: return@runCatching null
234-
lightningRepo.fetchLnurlInvoice(decoded.data.callback, satsToMsat(amount)).getOrThrow().bolt11
235-
}
241+
is Scanner.LnurlPay -> {
242+
val amount = amountSats ?: return@runSuspendCatching null
243+
lightningRepo.fetchLnurlInvoice(
244+
data = decoded.data,
245+
amountMsats = decoded.data.callbackAmountMsats(amount),
246+
).getOrThrow().bolt11
247+
}
236248

237-
else -> null
249+
else -> null
250+
}
251+
}.getOrElse {
252+
if (it is LnurlPayInvoiceMismatchError) throw it
253+
null
238254
}
239-
}.getOrNull()
255+
}
240256

241257
private suspend fun handleProbeOutcome(
242258
startTime: Long,
243259
outcome: ProbeOutcome,
244260
invoice: String?,
245261
amountSats: ULong?,
246262
) {
247-
val durationMs = System.currentTimeMillis() - startTime
263+
val durationMs = Clock.System.nowMs() - startTime
248264
when (outcome) {
249265
is ProbeOutcome.Success -> {
250266
Logger.info(
@@ -288,7 +304,7 @@ class ProbingToolViewModel @Inject constructor(
288304
}
289305

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

294310
val friendlyMessage = getFriendlyErrorMessage(error)

app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
1212
import org.lightningdevkit.ldknode.Event
1313
import org.lightningdevkit.ldknode.PaymentId
1414
import to.bitkit.ext.WatchResult
15+
import to.bitkit.ext.callbackAmountMsats
1516
import to.bitkit.ext.toUserMessage
1617
import to.bitkit.ext.watchUntil
1718
import to.bitkit.repositories.LightningRepo
@@ -48,8 +49,8 @@ class QuickPayViewModel @Inject constructor(
4849
is QuickPayData.LnurlPay -> {
4950
Logger.info("QuickPay: fetching LNURL Pay invoice from callback")
5051
val invoice = lightningRepo.fetchLnurlInvoice(
51-
callbackUrl = data.callback,
52-
amountMsats = data.amountMsats,
52+
data = data.data,
53+
amountMsats = data.data.callbackAmountMsats(data.sats),
5354
)
5455
.getOrElse { error ->
5556
_uiState.update {

0 commit comments

Comments
 (0)