Skip to content

Commit 0415486

Browse files
committed
Merge remote-tracking branch 'origin/master' into codex/paykit-sdk-native-integration
# Conflicts: # gradle/libs.versions.toml
2 parents 55013f6 + d25de50 commit 0415486

15 files changed

Lines changed: 214 additions & 103 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ jobs:
126126
E2E: true
127127
E2E_BACKEND: network
128128
GEO: false
129+
TREZOR_BRIDGE: true
130+
TREZOR_BRIDGE_URL: http://10.0.2.2:21325
129131
run: ./gradlew assembleDevDebug
130132

131133
- name: Rename APK
@@ -320,6 +322,7 @@ jobs:
320322
shard:
321323
- { name: multi_address_2_regtest, grep: "@multi_address_2" }
322324
- { name: pubky_paykit, grep: "@pubky" }
325+
- { name: hardware_wallet, grep: "@hardware_wallet" }
323326

324327
name: e2e-tests-staging - ${{ matrix.shard.name }}
325328

@@ -390,6 +393,13 @@ jobs:
390393
working-directory: bitkit-e2e-tests
391394
run: npm ci
392395

396+
- name: Pull Trezor emulator image
397+
if: matrix.shard.name == 'hardware_wallet'
398+
working-directory: bitkit-e2e-tests
399+
run: |
400+
cd docker
401+
docker compose --profile trezor pull --quiet trezor-user-env
402+
393403
- name: Run E2E Tests 1 (${{ matrix.shard.name }})
394404
continue-on-error: true
395405
id: test1

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
@@ -992,20 +994,20 @@ class LightningRepo @Inject constructor(
992994
runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) }
993995
}
994996

995-
@Suppress("ForbiddenComment")
996997
suspend fun fetchLnurlInvoice(
997-
callbackUrl: String,
998+
data: LnurlPayData,
998999
amountMsats: ULong,
9991000
comment: String? = null,
10001001
): Result<LightningInvoice> {
10011002
return runCatching {
1002-
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
1003-
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountMsats, comment).getOrThrow().pr
1003+
val bolt11 = coreService.getLnurlInvoiceForPayData(data, amountMsats, comment)
10041004
val decoded = (coreService.decode(bolt11) as Scanner.Lightning).invoice
10051005
return@runCatching decoded
1006+
}.recoverCatching {
1007+
throw it.toLnurlPayInvoiceError()
10061008
}.onFailure {
10071009
Logger.error(
1008-
"Failed to fetch LNURL invoice, url: '$callbackUrl', amountMsats: '$amountMsats', comment: '$comment'",
1010+
"Failed to fetch LNURL invoice, uri: '${data.uri}', amountMsats: '$amountMsats', comment: '$comment'",
10091011
it,
10101012
context = TAG,
10111013
)
@@ -1715,11 +1717,27 @@ class NodeStopTimeoutError : AppError("Timeout waiting for node to stop")
17151717
class NodeRunTimeoutError(opName: String) : AppError("Timeout waiting for node to run and execute: '$opName'")
17161718
class GetPaymentsError : AppError("It wasn't possible get the payments")
17171719
class SyncUnhealthyError : AppError("Wallet sync failed before send")
1720+
class LnurlPayInvoiceMismatchError : AppError("The invoice did not match the requested payment. Payment cancelled.")
17181721
sealed class ProbeError(message: String) : AppError(message) {
17191722
class NoProbeHandles : ProbeError("No probe handles returned")
17201723
class TimedOut : ProbeError("Probe timed out")
17211724
}
17221725

1726+
private fun Throwable.toLnurlPayInvoiceError(): Throwable {
1727+
val lnurlPayValidationError = generateSequence(this) { it.cause }
1728+
.firstOrNull { it.isLnurlPayValidationError() }
1729+
1730+
return if (lnurlPayValidationError != null) LnurlPayInvoiceMismatchError() else this
1731+
}
1732+
1733+
private fun Throwable.isLnurlPayValidationError(): Boolean = when (this) {
1734+
is LnurlException.InvalidAmount,
1735+
is LnurlException.AmountMismatch,
1736+
is LnurlException.MetadataMismatch -> true
1737+
1738+
else -> false
1739+
}
1740+
17231741
@Stable
17241742
data class LightningState(
17251743
val nodeId: String = "",

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,13 +1003,13 @@ class PubkyRepo @Inject constructor(
10031003

10041004
repeat(2) { attempt ->
10051005
val result = runSuspendCatching {
1006-
val profile = pubkyService.resolveContactProfile(
1006+
pubkyService.resolveContactProfile(
10071007
publicKey = prefixedKey,
10081008
allowPubkyProfileFallback = true,
10091009
)?.let(::profileFromResolution)
1010-
if (profile != null || attempt == 1) {
1011-
return@withContext profile
1012-
}
1010+
}
1011+
if (result.isSuccess && (result.getOrNull() != null || attempt == 1)) {
1012+
return@withContext result.getOrNull()
10131013
}
10141014
result.exceptionOrNull()?.let { error ->
10151015
lastError = error

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/ui/screens/wallets/activity/ActivityDetailScreen.kt

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -702,41 +702,40 @@ private fun ActivityDetailContent(
702702
}
703703
)
704704
)
705-
if (isTransfer && channelId != null && onChannelClick != null) {
706-
PrimaryButton(
707-
text = stringResource(R.string.lightning__connection),
708-
size = ButtonSize.Small,
709-
onClick = { onChannelClick(channelId) },
710-
icon = {
711-
Icon(
712-
painter = painterResource(R.drawable.ic_lightning),
713-
contentDescription = null,
714-
tint = accentColor,
715-
modifier = Modifier.size(16.dp)
716-
)
717-
},
718-
modifier = Modifier
719-
.weight(1f)
720-
.testTag("ChannelButton")
721-
)
722-
} else {
723-
PrimaryButton(
724-
text = stringResource(R.string.wallet__activity_explore),
725-
size = ButtonSize.Small,
726-
onClick = { onExploreClick(item.rawId()) },
727-
icon = {
728-
Icon(
729-
painter = painterResource(R.drawable.ic_git_branch),
730-
contentDescription = null,
731-
tint = accentColor,
732-
modifier = Modifier.size(16.dp)
733-
)
734-
},
735-
modifier = Modifier
736-
.weight(1f)
737-
.testTag("ActivityTxDetails")
738-
)
739-
}
705+
PrimaryButton(
706+
text = stringResource(R.string.wallet__activity_explore),
707+
size = ButtonSize.Small,
708+
onClick = { onExploreClick(item.rawId()) },
709+
icon = {
710+
Icon(
711+
painter = painterResource(R.drawable.ic_git_branch),
712+
contentDescription = null,
713+
tint = accentColor,
714+
modifier = Modifier.size(16.dp)
715+
)
716+
},
717+
modifier = Modifier
718+
.weight(1f)
719+
.testTag("ActivityTxDetails")
720+
)
721+
}
722+
if (isTransfer && channelId != null && onChannelClick != null) {
723+
PrimaryButton(
724+
text = stringResource(R.string.lightning__connection),
725+
size = ButtonSize.Small,
726+
onClick = { onChannelClick(channelId) },
727+
icon = {
728+
Icon(
729+
painter = painterResource(R.drawable.ic_lightning),
730+
contentDescription = null,
731+
tint = accentColor,
732+
modifier = Modifier.size(16.dp)
733+
)
734+
},
735+
modifier = Modifier
736+
.fillMaxWidth()
737+
.testTag("ChannelButton")
738+
)
740739
}
741740
}
742741
}

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

0 commit comments

Comments
 (0)