From a960a3499cc38535a2c57803272e694b5c296503 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 26 Jun 2026 05:04:59 +0200 Subject: [PATCH] fix: use core LNURL-pay validation --- app/src/main/java/to/bitkit/ext/Coroutines.kt | 7 ++ .../to/bitkit/repositories/LightningRepo.kt | 28 +++++-- .../java/to/bitkit/services/CoreService.kt | 10 +++ .../java/to/bitkit/services/LnurlService.kt | 30 -------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 16 ++-- .../bitkit/viewmodels/ProbingToolViewModel.kt | 50 ++++++++----- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 5 +- .../bitkit/repositories/LightningRepoTest.kt | 73 +++++++++++++++++++ changelog.d/hotfix/1048.fixed.md | 1 + gradle/libs.versions.toml | 2 +- 10 files changed, 162 insertions(+), 60 deletions(-) create mode 100644 changelog.d/hotfix/1048.fixed.md diff --git a/app/src/main/java/to/bitkit/ext/Coroutines.kt b/app/src/main/java/to/bitkit/ext/Coroutines.kt index 692cd985a5..4aa2faf26e 100644 --- a/app/src/main/java/to/bitkit/ext/Coroutines.kt +++ b/app/src/main/java/to/bitkit/ext/Coroutines.kt @@ -3,6 +3,13 @@ package to.bitkit.ext import kotlinx.coroutines.Job import to.bitkit.utils.Logger +@Suppress("TooGenericExceptionCaught") +suspend inline fun runSuspendCatching(crossinline block: suspend () -> T): Result = 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}") diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 4bbc4c9a65..45faadea22 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -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 @@ -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 { 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, ) @@ -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 = "", diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 6333f14803..b11bddae8d 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -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 @@ -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 @@ -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" } diff --git a/app/src/main/java/to/bitkit/services/LnurlService.kt b/app/src/main/java/to/bitkit/services/LnurlService.kt index f917b5ce4d..6442981ed3 100644 --- a/app/src/main/java/to/bitkit/services/LnurlService.kt +++ b/app/src/main/java/to/bitkit/services/LnurlService.kt @@ -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 = 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() - } - suspend fun requestLnurlChannel(url: String): Result = runCatching { Logger.debug("Requesting LNURL channel request via: '$url'", context = TAG) @@ -101,12 +77,6 @@ data class LnurlWithdrawResponse( val balanceCheck: String? = null, ) -@Serializable -data class LnurlPayResponse( - val pr: String, - val routes: List, -) - @Serializable data class LnurlChannelResponse( val status: String? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 52c10c5235..b092edfdc6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -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 @@ -2126,8 +2127,7 @@ class AppViewModel @Inject constructor( lnurlPay != null -> { QuickPayData.LnurlPay( sats = amountSats, - callback = lnurlPay.callback, - amountMsats = lnurlPay.callbackAmountMsats(amountSats), + data = lnurlPay, ) } @@ -2250,7 +2250,7 @@ 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 -> @@ -2258,7 +2258,8 @@ class AppViewModel @Inject constructor( 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 } @@ -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 { @@ -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 diff --git a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt index 372269bc8d..4fb1bfc13b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ProbingToolViewModel.kt @@ -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, @@ -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 @@ -145,7 +152,6 @@ class ProbingToolViewModel @Inject constructor( } } - val startTime = System.currentTimeMillis() val dispatch = if (isNodeIdTarget) { lightningRepo.sendProbeForNode(requireNotNull(nodeId), requireNotNull(amountSats)) } else { @@ -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) } } @@ -221,22 +229,30 @@ 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, @@ -244,7 +260,7 @@ class ProbingToolViewModel @Inject constructor( invoice: String?, amountSats: ULong?, ) { - val durationMs = System.currentTimeMillis() - startTime + val durationMs = Clock.System.nowMs() - startTime when (outcome) { is ProbeOutcome.Success -> { Logger.info( @@ -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) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 24f39567e9..0599d4ae42 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -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 @@ -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 { diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index c04e983a71..6f55b26951 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -6,6 +6,11 @@ import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.ILspNode +import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.LnurlException +import com.synonym.bitkitcore.LnurlPayData +import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.Scanner import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -31,6 +36,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.isNull import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.spy import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -55,6 +61,7 @@ import to.bitkit.services.LspNotificationsService import to.bitkit.services.NetworkGraphInfo import to.bitkit.services.NodeEventHandler import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError import to.bitkit.utils.UrlValidator import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -140,6 +147,29 @@ class LightningRepoTest : BaseUnitTest() { return requireNotNull(capturedHandler) } + private fun lnurlPayData() = LnurlPayData( + uri = "lnurl1test", + callback = "https://example.com/callback", + minSendable = 1_000uL, + maxSendable = 100_000uL, + metadataStr = "[[\"text/plain\",\"test\"]]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + + private fun lightningInvoice(bolt11: String) = LightningInvoice( + bolt11 = bolt11, + paymentHash = byteArrayOf(1, 2, 3), + amountSatoshis = 42uL, + timestampSeconds = 0uL, + expirySeconds = 3_600uL, + isExpired = false, + description = "test", + networkType = NetworkType.REGTEST, + payeeNodeId = null, + ) + @Test fun `start should transition through correct states`() = test { sut.setInitNodeLifecycleState() @@ -218,6 +248,49 @@ class LightningRepoTest : BaseUnitTest() { assertEquals(testInvoice, result.getOrNull()) } + @Test + fun `fetchLnurlInvoice delegates to core and decodes after success`() = test { + val data = lnurlPayData() + val invoice = lightningInvoice("lnbc1") + whenever(coreService.getLnurlInvoiceForPayData(data, 42_000uL, "thanks")).thenReturn("lnbc1") + whenever(coreService.decode("lnbc1")).thenReturn(Scanner.Lightning(invoice)) + + val result = sut.fetchLnurlInvoice(data, 42_000uL, "thanks") + + assertTrue(result.isSuccess) + assertEquals(invoice, result.getOrThrow()) + verify(coreService).getLnurlInvoiceForPayData(data, 42_000uL, "thanks") + verify(coreService).decode("lnbc1") + } + + @Test + fun `fetchLnurlInvoice maps core validation error and skips decode`() = test { + val data = lnurlPayData() + whenever(coreService.getLnurlInvoiceForPayData(data, 42_000uL, null)) + .thenAnswer { throw LnurlException.AmountMismatch(42_000uL, 43_000uL) } + + val result = sut.fetchLnurlInvoice(data, 42_000uL) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + verify(coreService).getLnurlInvoiceForPayData(data, 42_000uL, null) + verify(coreService, never()).decode(any()) + } + + @Test + fun `fetchLnurlInvoice maps wrapped core validation error and skips decode`() = test { + val data = lnurlPayData() + whenever(coreService.getLnurlInvoiceForPayData(data, 42_000uL, null)) + .thenAnswer { throw AppError(LnurlException.AmountMismatch(42_000uL, 43_000uL)) } + + val result = sut.fetchLnurlInvoice(data, 42_000uL) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + verify(coreService).getLnurlInvoiceForPayData(data, 42_000uL, null) + verify(coreService, never()).decode(any()) + } + @Test fun `payInvoice should fail when node is not running`() = test { val result = sut.payInvoice("bolt11", 1000uL) diff --git a/changelog.d/hotfix/1048.fixed.md b/changelog.d/hotfix/1048.fixed.md new file mode 100644 index 0000000000..3a4d4d29fb --- /dev/null +++ b/changelog.d/hotfix/1048.fixed.md @@ -0,0 +1 @@ +Improved LNURL-pay invoice validation. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8659a9550..bcdd57f2b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.67" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.74" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }