Skip to content

Commit 36d69cb

Browse files
authored
Merge branch 'master' into feat/fg-service-optional
2 parents 80fd775 + d25de50 commit 36d69cb

11 files changed

Lines changed: 165 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.3.1] - 2026-06-26
11+
12+
### Fixed
13+
- Improved LNURL-pay invoice validation. #1048
14+
- Improved LNURL-pay payment handling. #1051
15+
1016
## [2.3.0] - 2026-06-05
1117

1218
### Added
@@ -94,6 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
94100
- About screen (content merged into Support) #857
95101
- Standalone General, Security, and Advanced settings screens (merged into tabs) #857
96102

97-
[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.3.0...HEAD
103+
[Unreleased]: https://github.com/synonymdev/bitkit-android/compare/v2.3.1...HEAD
104+
[2.3.1]: https://github.com/synonymdev/bitkit-android/compare/v2.3.0...v2.3.1
98105
[2.3.0]: https://github.com/synonymdev/bitkit-android/compare/v2.2.0...v2.3.0
99106
[2.2.0]: https://github.com/synonymdev/bitkit-android/compare/v2.1.2...v2.2.0

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ android {
169169
applicationId = "to.bitkit"
170170
minSdk = 28
171171
targetSdk = 36
172-
versionCode = 182
173-
versionName = "2.3.0"
172+
versionCode = 184
173+
versionName = "2.3.1"
174174
testInstrumentationRunner = "to.bitkit.test.HiltTestRunner"
175175
bitkitAndroidTestAnnotation?.let {
176176
testInstrumentationRunnerArguments["annotation"] = it

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import kotlinx.coroutines.Job
55
import to.bitkit.utils.Logger
66

77
@Suppress("TooGenericExceptionCaught")
8-
suspend inline fun <R> runSuspendCatching(block: () -> R): Result<R> =
8+
suspend inline fun <T> runSuspendCatching(crossinline block: suspend () -> T): Result<T> =
99
try {
1010
Result.success(block())
1111
} catch (c: CancellationException) {

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
@@ -996,20 +998,20 @@ class LightningRepo @Inject constructor(
996998
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
997999
}
9981000

999-
@Suppress("ForbiddenComment")
10001001
suspend fun fetchLnurlInvoice(
1001-
callbackUrl: String,
1002+
data: LnurlPayData,
10021003
amountMsats: ULong,
10031004
comment: String? = null,
10041005
): Result<LightningInvoice> {
10051006
return runCatching {
1006-
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
1007-
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr
1007+
val bolt11 = coreService.getLnurlInvoiceForPayData(data, amountMsats, comment)
10081008
val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice
10091009
return@runCatching decoded
1010+
}.recoverCatching {
1011+
throw it.toLnurlPayInvoiceError()
10101012
}.onFailure {
10111013
Logger.error(
1012-
"Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'",
1014+
"Failed to fetch LNURL invoice, uri: '${data.uri}', amountMsats: '$amountMsats', comment: '$comment'",
10131015
it,
10141016
context = TAG,
10151017
)
@@ -1719,11 +1721,27 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
17191721
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
17201722
class GetPaymentsError : AppError("It wasn't possible get the payments")
17211723
class SyncUnhealthyError : AppError("Wallet sync failed before send")
1724+
class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.")
17221725
sealed class ProbeError(message: String) : AppError(message) {
17231726
class NoProbeHandles : ProbeError("No probe handles returned")
17241727
class TimedOut : ProbeError("Probe timed out")
17251728
}
17261729

1730+
private fun Throwable.toLnurlPayInvoiceError(): Throwable {
1731+
val lnurlPayValidationError = generateSequence(this) { it.cause }
1732+
.firstOrNull { it.isLnurlPayValidationError() }
1733+
1734+
return if (lnurlPayValidationError != null) LnurlPayInvoiceMismatchError() else this
1735+
}
1736+
1737+
private fun Throwable.isLnurlPayValidationError(): Boolean = when (this) {
1738+
is LnurlException.InvalidAmount,
1739+
is LnurlException.AmountMismatch,
1740+
is LnurlException.MetadataMismatch -> true
1741+
1742+
else -> false
1743+
}
1744+
17271745
@Stable
17281746
data class LightningState(
17291747
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
@@ -127,6 +127,7 @@ import to.bitkit.repositories.CurrencyRepo
127127
import to.bitkit.repositories.HealthRepo
128128
import to.bitkit.repositories.HwWalletRepo
129129
import to.bitkit.repositories.LightningRepo
130+
import to.bitkit.repositories.LnurlPayInvoiceMismatchError
130131
import to.bitkit.repositories.PaymentPendingException
131132
import to.bitkit.repositories.PendingPaymentNotification
132133
import to.bitkit.repositories.PendingPaymentRepo
@@ -2145,8 +2146,7 @@ class AppViewModel @Inject constructor(
21452146
lnurlPay != null -> {
21462147
QuickPayData.LnurlPay(
21472148
sats = amountSats,
2148-
callback = lnurlPay.callback,
2149-
amountMsats = lnurlPay.callbackAmountMsats(amountSats),
2149+
data = lnurlPay,
21502150
)
21512151
}
21522152

@@ -2269,15 +2269,16 @@ class AppViewModel @Inject constructor(
22692269
if (isLnurlPay) {
22702270
val amountMsats = lnurl.data.callbackAmountMsats(amount)
22712271
lightningRepo.fetchLnurlInvoice(
2272-
callbackUrl = lnurl.data.callback,
2272+
data = lnurl.data,
22732273
amountMsats = amountMsats,
22742274
comment = _sendUiState.value.comment.takeIf { it.isNotEmpty() },
22752275
).onSuccess { invoice ->
22762276
_sendUiState.update {
22772277
it.copy(decodedInvoice = invoice)
22782278
}
22792279
}.onFailure {
2280-
toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch)))
2280+
val message = getLnurlInvoiceFetchErrorMessage(it)
2281+
toast(Exception(message))
22812282
hideSheet()
22822283
return
22832284
}
@@ -2378,6 +2379,11 @@ class AppViewModel @Inject constructor(
23782379
}
23792380
}
23802381

2382+
private fun getLnurlInvoiceFetchErrorMessage(error: Throwable): String = when (error) {
2383+
is LnurlPayInvoiceMismatchError -> context.getString(R.string.lightning__order_state__payment_canceled)
2384+
else -> context.getString(R.string.wallet__error_lnurl_invoice_fetch)
2385+
}
2386+
23812387
fun onConfirmWithdraw() {
23822388
_sendUiState.update { it.copy(isLoading = true) }
23832389
viewModelScope.launch {
@@ -3398,7 +3404,7 @@ sealed interface QuickPayData {
33983404
data class Bolt11(override val sats: ULong, val bolt11: String) : QuickPayData
33993405

34003406
@Stable
3401-
data class LnurlPay(override val sats: ULong, val callback: String, val amountMsats: ULong) : QuickPayData
3407+
data class LnurlPay(override val sats: ULong, val data: LnurlPayData) : QuickPayData
34023408
}
34033409
// endregion
34043410

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)