From e6c4fffda38b1c4e71001bfc041627b0e3f075bb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 1 May 2026 10:22:01 +0200 Subject: [PATCH 1/8] chore: lint --- .../to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt | 2 +- app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt | 2 +- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 2 +- app/src/main/java/to/bitkit/services/CoreService.kt | 4 +--- app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt | 2 +- .../to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt | 2 +- .../ui/settings/lightning/LightningConnectionsScreen.kt | 2 +- 7 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 1992e08cd..629b84842 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -17,8 +17,8 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.ActivityRepo import to.bitkit.models.msatCeilOf +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.Logger import javax.inject.Inject diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index fe9a86efc..6572848a8 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -26,7 +26,6 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.toUserMessage import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.msatCeilOf import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived import to.bitkit.models.BlocktankNotificationType.incomingHtlc import to.bitkit.models.BlocktankNotificationType.mutualClose @@ -36,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails +import to.bitkit.models.msatCeilOf import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 199e2ff3e..dde2ee0a4 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -32,8 +32,8 @@ import to.bitkit.ext.toHex import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState -import to.bitkit.models.msatFloorOf import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.msatFloorOf import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4349eced4..a5f8dc387 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -72,11 +72,11 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats -import to.bitkit.models.msatFloorOf import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.addressTypeFromAddress +import to.bitkit.models.msatFloorOf import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -1334,8 +1334,6 @@ class ActivityService( // Check if any input spends a closed channel's funding UTXO (commitment tx) findChannelByFundingUtxo(details, closedChannelsList) - // Check if any input's parent transaction is a channel-related activity - // (e.g., sweep tx spending from commitment tx) ?: findChannelByParentActivity(details) }.onFailure { e -> Logger.warn("Failed to check if transaction $txid spends closed channel funding UTXO", e, context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 1b6f70840..43b87cd91 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -50,11 +50,11 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri -import to.bitkit.models.msatFloorOf import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer import to.bitkit.models.alias import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 0b93407ac..a41243741 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -60,8 +60,8 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText -import to.bitkit.models.msatFloorOf import to.bitkit.models.Toast +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 89f42eb0f..4c1913954 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -47,8 +47,8 @@ import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails -import to.bitkit.models.msatFloorOf import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB From 3851847751cff10e82c20263c8cea40cb568beac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 1 May 2026 13:08:44 +0200 Subject: [PATCH 2/8] fix: stabilize send scan routing --- .../to/bitkit/repositories/LightningRepo.kt | 12 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 126 ++++++++---------- 2 files changed, 68 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1d3eb6b40..9d428da52 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1019,12 +1019,20 @@ class LightningRepo @Inject constructor( } suspend fun waitForUsableChannels() = withContext(bgDispatcher) { - if (_lightningState.value.channels.any { it.isUsable }) return@withContext + val state = _lightningState.value + if (!state.nodeLifecycleState.canRun()) return@withContext + + if (state.channels.isEmpty()) return@withContext // no channel exists, don't wait + if (state.channels.any { it.isUsable }) return@withContext Logger.info("Waiting for usable channels before sending payment", context = TAG) withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { - _lightningState.first { state -> state.channels.any { it.isUsable } } + _lightningState.first { + !it.nodeLifecycleState.canRun() || + it.channels.isEmpty() || + it.channels.any { channel -> channel.isUsable } + } } ?: Logger.warn("Timed out waiting for usable channels", context = TAG) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0152e82a3..7e9e950a5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1306,6 +1306,7 @@ class AppViewModel @Inject constructor( resetSendState() resetQuickPay() + val fromMainScanner = isMainScanner val input = result.removeLightningSchemes() // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 @@ -1343,16 +1344,12 @@ class AppViewModel @Inject constructor( .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() - if (isMainScanner && scan.isLightningRelated()) { - showSheet(Sheet.Send()) - } - when (scan) { - is Scanner.OnChain -> onScanOnchain(scan.invoice, input) - is Scanner.Lightning -> onScanLightning(scan.invoice, input) - is Scanner.LnurlPay -> onScanLnurlPay(scan.data) - is Scanner.LnurlWithdraw -> onScanLnurlWithdraw(scan.data) - is Scanner.LnurlAuth -> onScanLnurlAuth(scan.data) + is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) + is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) + is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) + is Scanner.LnurlWithdraw -> onScanLnurlWithdraw(scan.data, fromMainScanner) + is Scanner.LnurlAuth -> onScanLnurlAuth(scan.data, fromMainScanner) is Scanner.LnurlChannel -> onScanLnurlChannel(scan.data) is Scanner.NodeId -> onScanNodeId(scan) is Scanner.Gift -> onScanGift(scan.code, scan.amount) @@ -1369,7 +1366,11 @@ class AppViewModel @Inject constructor( } @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") - private suspend fun onScanOnchain(invoice: OnChainInvoice, scanResult: String) { + private suspend fun onScanOnchain( + invoice: OnChainInvoice, + scanResult: String, + fromMainScanner: Boolean, + ) { val validatedAddress = runCatching { validateBitcoinAddress(invoice.address) } .getOrElse { hideSheet() @@ -1416,14 +1417,11 @@ class AppViewModel @Inject constructor( val quickPayHandled = handleQuickPayIfApplicable( amountSats = lnAmountSats, invoice = lnInvoice, + fromMainScanner = fromMainScanner, ) if (quickPayHandled) return - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) refreshOnchainSendIfNeeded() estimateLightningRoutingFeesIfNeeded() return @@ -1465,14 +1463,14 @@ class AppViewModel @Inject constructor( context = TAG, ) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLightning(invoice: LightningInvoice, scanResult: String) { + private suspend fun onScanLightning( + invoice: LightningInvoice, + scanResult: String, + fromMainScanner: Boolean, + ) { if (invoice.isExpired) { hideSheet() toast( @@ -1484,7 +1482,11 @@ class AppViewModel @Inject constructor( return } - val quickPayHandled = handleQuickPayIfApplicable(amountSats = invoice.amountSatoshis, invoice = invoice) + val quickPayHandled = handleQuickPayIfApplicable( + amountSats = invoice.amountSatoshis, + invoice = invoice, + fromMainScanner = fromMainScanner, + ) if (quickPayHandled) return lightningRepo.waitForUsableChannels() @@ -1515,23 +1517,15 @@ class AppViewModel @Inject constructor( if (invoice.amountSatoshis > 0uL) { Logger.info("Found amount in invoice, proceeding with payment", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } Logger.info("No amount found in invoice, proceeding to enter amount", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlPay(data: LnurlPayData) { + private suspend fun onScanLnurlPay(data: LnurlPayData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -1561,26 +1555,22 @@ class AppViewModel @Inject constructor( if (isFixed) { Logger.info("Found fixed amount '$displaySats' sats in lnurlPay, proceeding with payment", context = TAG) - val quickPayHandled = handleQuickPayIfApplicable(amountSats = displaySats, lnurlPay = data) + val quickPayHandled = handleQuickPayIfApplicable( + amountSats = displaySats, + lnurlPay = data, + fromMainScanner = fromMainScanner, + ) if (quickPayHandled) return - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Confirm)) - } else { - setSendEffect(SendEffect.NavigateToConfirm) - } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) return } Logger.info("No amount found in lnurlPay, proceeding to enter amount manually", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData) { + private suspend fun onScanLnurlWithdraw(data: LnurlWithdrawData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) val isFixed = data.isFixedAmount() @@ -1609,30 +1599,39 @@ class AppViewModel @Inject constructor( if (isFixed || minWithdrawable == maxWithdrawable) { delay(TRANSITION_SCREEN_MS) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.WithdrawConfirm)) - } else { - setSendEffect(SendEffect.NavigateToWithdrawConfirm) - } + navigateToSendRoute( + fromMainScanner, + SendRoute.WithdrawConfirm, + SendEffect.NavigateToWithdrawConfirm, + ) return } - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.Amount)) - } else { - setSendEffect(SendEffect.NavigateToAmount) - } + navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } - private suspend fun onScanLnurlAuth(data: LnurlAuthData) { + private suspend fun onScanLnurlAuth(data: LnurlAuthData, fromMainScanner: Boolean) { Logger.debug("LNURL: $data", context = TAG) - if (!isMainScanner) { + if (!fromMainScanner) { hideSheet() delay(TRANSITION_SCREEN_MS) } showSheet(Sheet.LnurlAuth(domain = data.domain, lnurl = data.uri, k1 = data.k1)) } + private fun navigateToSendRoute( + fromMainScanner: Boolean, + route: SendRoute, + effect: SendEffect, + ) { + if (fromMainScanner) { + showSheet(Sheet.Send(route)) + return + } + + setSendEffect(effect) + } + fun requestLnurlAuth(callback: String, k1: String, domain: String) { viewModelScope.launch { lightningRepo.requestLnurlAuth( @@ -1700,6 +1699,7 @@ class AppViewModel @Inject constructor( private suspend fun handleQuickPayIfApplicable( amountSats: ULong, + fromMainScanner: Boolean, lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { @@ -1733,11 +1733,7 @@ class AppViewModel @Inject constructor( Logger.debug("QuickPayData: $quickPayData", context = TAG) - if (isMainScanner) { - showSheet(Sheet.Send(SendRoute.QuickPay)) - } else { - setSendEffect(SendEffect.NavigateToQuickPay) - } + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) return true } @@ -2627,12 +2623,6 @@ class AppViewModel @Inject constructor( } } -private fun Scanner?.isLightningRelated(): Boolean = when (this) { - is Scanner.Lightning, is Scanner.LnurlPay -> true - is Scanner.OnChain -> invoice.params?.containsKey("lightning") == true - else -> false -} - // region send contract @Stable data class SendUiState( From 9bcb59b59529301a9d196b10a881278b5df6403b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 8 May 2026 01:34:06 +0200 Subject: [PATCH 3/8] fix: use const for bolt11 expiry default --- app/src/main/java/to/bitkit/env/Env.kt | 2 +- .../main/java/to/bitkit/repositories/BlocktankRepo.kt | 3 ++- .../main/java/to/bitkit/repositories/LightningRepo.kt | 5 +++-- app/src/main/java/to/bitkit/services/CoreService.kt | 9 +++++++-- .../main/java/to/bitkit/services/LightningService.kt | 4 ++-- .../to/bitkit/ui/settings/BlocktankRegtestScreen.kt | 2 +- .../to/bitkit/ui/settings/BlocktankRegtestViewModel.kt | 7 ++++++- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 10 +++++++--- 8 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 3a1c0bf63..eddab2ffd 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -246,7 +246,7 @@ internal object Env { @Suppress("ConstPropertyName") object Defaults { /** Default Bolt11 invoice expiry in seconds. */ - const val bolt11InvoiceExpirySeconds = 3_600u + const val bolt11ExpirySec = 86_400u /** Recommended transaction base fee in sats */ const val recommendedBaseFee = 256u diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index d5c7b44d4..5d54b9783 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -57,6 +57,7 @@ import javax.inject.Named import javax.inject.Singleton import kotlin.math.ceil import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds @Singleton @@ -463,7 +464,7 @@ class BlocktankRepo @Inject constructor( val invoice = lightningRepo.createInvoice( amountSats = null, description = "blocktank-gift-code:$code", - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = 1.hours.inWholeSeconds.toUInt(), ).getOrThrow() Logger.debug("Created invoice for gift code, requesting payment from LSP", context = TAG) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a63c3bf27..a5f245b3b 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -58,6 +58,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.backup.VssBackupClientLdk import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.nowTimestamp @@ -926,7 +927,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoice( amountSats: ULong? = null, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoice") { updateGeoBlockState() runCatching { lightningService.receive(amountSats, description, expirySeconds) } @@ -935,7 +936,7 @@ class LightningRepo @Inject constructor( suspend fun createInvoiceMsats( amountMsats: ULong, description: String, - expirySeconds: UInt = 86_400u, + expirySeconds: UInt = Defaults.bolt11ExpirySec, ): Result = executeWhenNodeRunning("createInvoiceMsats") { updateGeoBlockState() runCatching { lightningService.receiveMsats(amountMsats, description, expirySeconds) } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 3bc9e76a2..c11482d6d 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -70,6 +70,7 @@ import org.lightningdevkit.ldknode.TransactionDetails import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.amountSats import to.bitkit.ext.channelId @@ -1521,11 +1522,15 @@ class BlocktankService( ) } - suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String { + suspend fun regtestCloseChannel( + fundingTxId: String, + vout: UInt, + forceCloseAfterS: UInt = Defaults.bolt11ExpirySec, + ): String { return com.synonym.bitkitcore.regtestCloseChannel( fundingTxId = fundingTxId, vout = vout, - forceCloseAfterS = forceCloseAfterS, + forceCloseAfterS = forceCloseAfterS.toULong(), ) } } diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 275402438..979bb6459 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -595,7 +595,7 @@ class LightningService @Inject constructor( suspend fun receive( sat: ULong? = null, description: String, - expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySecs: UInt = Defaults.bolt11ExpirySec, ): String { return receiveMsats(amountMsat = sat?.let { it * 1000u }, description = description, expirySecs = expirySecs) } @@ -603,7 +603,7 @@ class LightningService @Inject constructor( suspend fun receiveMsats( amountMsat: ULong? = null, description: String, - expirySecs: UInt = Defaults.bolt11InvoiceExpirySeconds, + expirySecs: UInt = Defaults.bolt11ExpirySec, ): String { val node = this.node ?: throw ServiceError.NodeNotSetup() diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index fcc6ff45c..8c7da3f38 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -270,7 +270,7 @@ fun BlocktankRegtestScreen( runCatching { val voutNum = vout.toUIntOrNull() ?: error("Invalid Vout: $vout") val closeAfter = - forceCloseAfter.toULongOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") + forceCloseAfter.toUIntOrNull() ?: error("Invalid Force Close After: $forceCloseAfter") val closingTxId = viewModel.regtestCloseChannel( fundingTxId = fundingTxId, vout = voutNum, diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt index f6cf74e20..1a915251c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestViewModel.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import to.bitkit.env.Defaults import to.bitkit.services.CoreService import javax.inject.Inject @@ -28,7 +29,11 @@ class BlocktankRegtestViewModel @Inject constructor( ) } - suspend fun regtestCloseChannel(fundingTxId: String, vout: UInt, forceCloseAfterS: ULong = 86_400uL): String { + suspend fun regtestCloseChannel( + fundingTxId: String, + vout: UInt, + forceCloseAfterS: UInt = Defaults.bolt11ExpirySec, + ): String { return coreService.blocktank.regtestCloseChannel( fundingTxId = fundingTxId, vout = vout, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 306b8d64c..d62ebb0e0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1444,7 +1444,11 @@ class AppViewModel @Inject constructor( } @Suppress("CyclomaticComplexMethod") - private suspend fun handleDecodedScan(scan: Scanner?, input: String) = when (scan) { + private suspend fun handleDecodedScan( + scan: Scanner?, + input: String, + fromMainScanner: Boolean = false, + ) = when (scan) { is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) is Scanner.LnurlPay -> onScanLnurlPay(scan.data, fromMainScanner) @@ -2090,7 +2094,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoiceMsats( amountMsats = lnurl.data.maxWithdrawable, description = lnurl.data.defaultDescription, - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = 3_600u, ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( @@ -2100,7 +2104,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoice( amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, - expirySeconds = Defaults.bolt11InvoiceExpirySeconds, + expirySeconds = 3_600u, ) }.getOrNull() From aeee3017378cf20eb2798bb39aa07e9486b993a8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 8 May 2026 01:37:48 +0200 Subject: [PATCH 4/8] chore: restore comment --- app/src/main/java/to/bitkit/services/CoreService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index c11482d6d..8baa73bb5 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1336,6 +1336,8 @@ class ActivityService( // Check if any input spends a closed channel's funding UTXO (commitment tx) findChannelByFundingUtxo(details, closedChannelsList) + // Check if any input's parent transaction is a channel-related activity + // (e.g., sweep tx spending from commitment tx) ?: findChannelByParentActivity(details) }.onFailure { e -> Logger.warn("Failed to check if transaction $txid spends closed channel funding UTXO", e, context = TAG) From c39ceb5ac2f74d6a220f18a63faa31b4866e8bea Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 8 May 2026 01:56:32 +0200 Subject: [PATCH 5/8] fix: preserve main scanner routing --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d62ebb0e0..4908e8a93 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1440,7 +1440,7 @@ class AppViewModel @Inject constructor( .onSuccess { Logger.info("Handling decoded scan data: $it", context = TAG) } .getOrNull() - handleDecodedScan(scan, input) + handleDecodedScan(scan, input, fromMainScanner) } @Suppress("CyclomaticComplexMethod") diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 41dd84eb1..cbc464a6b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -371,6 +371,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(contactKey, pendingContactPaymentContext(paymentHash)?.publicKey) } + @Test + fun `main scanner lightning scan opens send sheet`() = test { + val bolt11 = "lnbcrt1scanner" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" From 10cbfdf6603f83b3223d1bd64eeda5391e05825b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 8 May 2026 01:58:58 +0200 Subject: [PATCH 6/8] fix: magic number use in constant --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4908e8a93..0f9744b2b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -155,6 +155,7 @@ import java.math.BigDecimal import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -2094,7 +2095,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoiceMsats( amountMsats = lnurl.data.maxWithdrawable, description = lnurl.data.defaultDescription, - expirySeconds = 3_600u, + expirySeconds = LNURL_EXPIRY_SEC, ) } else { val withdrawAmountSats = _sendUiState.value.amount.coerceAtLeast( @@ -2104,7 +2105,7 @@ class AppViewModel @Inject constructor( lightningRepo.createInvoice( amountSats = withdrawAmountSats, description = lnurl.data.defaultDescription, - expirySeconds = 3_600u, + expirySeconds = LNURL_EXPIRY_SEC, ) }.getOrNull() @@ -2804,6 +2805,7 @@ class AppViewModel @Inject constructor( private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val PUBKYAUTH_SCHEME = "pubkyauth" + private val LNURL_EXPIRY_SEC = 1.hours.inWholeSeconds.toUInt() } } From bdc6d952f7dce572e08f69faf7c5ec7570eb5bc6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 8 May 2026 02:03:52 +0200 Subject: [PATCH 7/8] test: cover scanner routing --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 0f9744b2b..908867ab6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1448,7 +1448,7 @@ class AppViewModel @Inject constructor( private suspend fun handleDecodedScan( scan: Scanner?, input: String, - fromMainScanner: Boolean = false, + fromMainScanner: Boolean, ) = when (scan) { is Scanner.OnChain -> onScanOnchain(scan.invoice, input, fromMainScanner) is Scanner.Lightning -> onScanLightning(scan.invoice, input, fromMainScanner) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cbc464a6b..99bf1e647 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -383,6 +383,32 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) } + @Test + fun `main scanner zero amount lightning scan opens amount sheet`() = test { + val bolt11 = "lnbcrt1zeroamount" + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + } + + @Test + fun `main scanner lightning scan opens QuickPay when enabled`() = test { + val bolt11 = "lnbcrt1scannerquickpay" + enableQuickPay(thresholdSats = 1000u) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + + sut.showScannerSheet() + sut.onScannerSheetResult(bolt11) + advanceUntilIdle() + + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + } + @Test fun `lightning scan uses QuickPay when enabled`() = test { val bolt11 = "lnbcrt1quickpay" From bf5e8f06a54514a759a1cadee73ee61004ef5925 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 8 May 2026 02:32:47 +0200 Subject: [PATCH 8/8] fix: wait for channel state --- .../to/bitkit/repositories/LightningRepo.kt | 43 +++++++++++++++---- .../bitkit/repositories/LightningRepoTest.kt | 19 ++++++++ changelog.d/next/925.fixed.md | 1 + 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 changelog.d/next/925.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index a5f245b3b..82dda5e1e 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -1020,23 +1020,50 @@ class LightningRepo @Inject constructor( } suspend fun waitForUsableChannels() = withContext(bgDispatcher) { - val state = _lightningState.value + var state = _lightningState.value + if (!state.nodeLifecycleState.canRun()) return@withContext + if (state.hasUsableChannels()) return@withContext + + state = waitForChannelsToLoadIfNeeded(state) ?: return@withContext if (!state.nodeLifecycleState.canRun()) return@withContext - if (state.channels.isEmpty()) return@withContext // no channel exists, don't wait - if (state.channels.any { it.isUsable }) return@withContext + if (state.channels.isEmpty()) { + if (state.nodeLifecycleState.isRunning()) { + syncState() + state = _lightningState.value + } + + if (state.channels.isEmpty()) return@withContext // no channel exists, don't wait + if (state.hasUsableChannels()) return@withContext + } Logger.info("Waiting for usable channels before sending payment", context = TAG) withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { - _lightningState.first { - !it.nodeLifecycleState.canRun() || - it.channels.isEmpty() || - it.channels.any { channel -> channel.isUsable } - } + _lightningState.first { it.shouldStopWaitingForUsableChannels() } } ?: Logger.warn("Timed out waiting for usable channels", context = TAG) } + private suspend fun waitForChannelsToLoadIfNeeded(state: LightningState): LightningState? { + if (state.channels.isNotEmpty() || state.nodeLifecycleState.isRunning()) return state + + Logger.info("Waiting for node to load channels before sending payment", context = TAG) + return withTimeoutOrNull(CHANNELS_USABLE_TIMEOUT_MS) { + _lightningState.first { it.shouldStopWaitingForLoadedChannels() } + } ?: run { + Logger.warn("Timed out waiting for node to load channels", context = TAG) + null + } + } + + private fun LightningState.hasUsableChannels() = channels.any { it.isUsable } + + private fun LightningState.shouldStopWaitingForLoadedChannels() = + !nodeLifecycleState.canRun() || nodeLifecycleState.isRunning() || channels.isNotEmpty() + + private fun LightningState.shouldStopWaitingForUsableChannels() = + !nodeLifecycleState.canRun() || channels.isEmpty() || hasUsableChannels() + @Suppress("LongParameterList") suspend fun sendOnChain( address: Address, diff --git a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt index 2d23942f3..7078603ac 100644 --- a/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/LightningRepoTest.kt @@ -392,6 +392,25 @@ class LightningRepoTest : BaseUnitTest() { assertFalse(sut.canSend(1000uL)) } + @Test + fun `waitForUsableChannels waits for running state before treating empty channels as absent`() = test { + sut.setInitNodeLifecycleState() + val channel = createChannelDetails().copy( + isUsable = true, + nextOutboundHtlcLimitMsat = 2_000_000u, + ) + whenever(lightningService.channels).thenReturn(listOf(channel)) + + val wait = async { sut.waitForUsableChannels() } + + assertFalse(wait.isCompleted) + + startNodeForTesting() + + assertTrue(wait.isCompleted) + assertEquals(listOf(channel), sut.lightningState.value.channels) + } + @Test fun `wipeStorage should stop node and call service wipe`() = test { startNodeForTesting() diff --git a/changelog.d/next/925.fixed.md b/changelog.d/next/925.fixed.md new file mode 100644 index 000000000..1e44ac09f --- /dev/null +++ b/changelog.d/next/925.fixed.md @@ -0,0 +1 @@ +Payment QR scans now route reliably and avoid unnecessary delays when Lightning channels are unavailable.