From 450a4b88446b5bc24d02a9b5c09f77294a74566c Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 3 Sep 2026 12:37:59 +0200 Subject: [PATCH 01/11] fix(receive): handle additional receive liquidity edge cases --- .../bitkit/models/ReceiveLiquidityDecision.kt | 85 ++++++++ .../to/bitkit/repositories/BlocktankRepo.kt | 50 +++++ .../java/to/bitkit/repositories/WalletRepo.kt | 33 ++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 26 +-- .../wallets/receive/EditInvoiceScreen.kt | 91 ++++++--- .../screens/wallets/receive/EditInvoiceVM.kt | 63 ++++-- .../wallets/receive/ReceiveAmountScreen.kt | 59 +++++- .../wallets/receive/ReceiveCjitErrors.kt | 12 ++ .../wallets/receive/ReceiveInvoiceUtils.kt | 6 +- .../wallets/receive/ReceiveQrScreen.kt | 70 +++++-- .../screens/wallets/receive/ReceiveSheet.kt | 26 ++- app/src/main/java/to/bitkit/utils/Errors.kt | 1 + .../bitkit/viewmodels/BlocktankViewModel.kt | 4 + app/src/main/res/values/strings.xml | 6 +- .../models/ReceiveLiquidityDecisionTest.kt | 181 ++++++++++++++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 46 ----- .../wallets/receive/EditInvoiceVMTest.kt | 91 ++++++--- .../receive/ReceiveInvoiceUtilsTest.kt | 48 ++++- changelog.d/next/1222.fixed.md | 1 + docs/receive-liquidity.md | 59 ++++++ 20 files changed, 770 insertions(+), 188 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt create mode 100644 app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt create mode 100644 changelog.d/next/1222.fixed.md create mode 100644 docs/receive-liquidity.md diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt new file mode 100644 index 0000000000..43edaf2b09 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -0,0 +1,85 @@ +package to.bitkit.models + +enum class ReceiveLiquiditySource { + SAVINGS, + AUTO, + SPENDING, +} + +sealed interface ReceiveAdditionalLiquidityAction { + data object None : ReceiveAdditionalLiquidityAction + data object ChooseAmount : ReceiveAdditionalLiquidityAction + data class CreateCjit(val amountSats: ULong) : ReceiveAdditionalLiquidityAction + data object GeoBlocked : ReceiveAdditionalLiquidityAction +} + +data class ReceiveAdditionalLiquidityParams( + val source: ReceiveLiquiditySource, + val invoiceAmountSats: ULong, + val inboundCapacitySats: ULong?, + val minCjitSats: ULong?, + val maxCjitAmountSats: ULong?, + val isGeoBlocked: Boolean, +) + +object ReceiveLiquidityDecision { + fun canCreateLightningInvoice( + hasReadyChannels: Boolean, + inboundCapacitySats: ULong?, + invoiceAmountSats: ULong?, + ): Boolean { + if (!hasReadyChannels || inboundCapacitySats == null) return false + + if (invoiceAmountSats == null || invoiceAmountSats == 0uL) { + return inboundCapacitySats > 0uL + } + + return invoiceAmountSats <= inboundCapacitySats + } + + fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams): ReceiveAdditionalLiquidityAction { + return when { + params.source != ReceiveLiquiditySource.SPENDING -> ReceiveAdditionalLiquidityAction.None + !needsInboundLiquidity(params.invoiceAmountSats, params.inboundCapacitySats) -> + ReceiveAdditionalLiquidityAction.None + (params.inboundCapacitySats ?: 0uL) == 0uL -> ReceiveAdditionalLiquidityAction.None + params.isGeoBlocked -> ReceiveAdditionalLiquidityAction.GeoBlocked + shouldChooseAmount(params) -> ReceiveAdditionalLiquidityAction.ChooseAmount + else -> ReceiveAdditionalLiquidityAction.CreateCjit(params.invoiceAmountSats) + } + } + + fun needsCjitLimitsForAdditionalLiquidity( + source: ReceiveLiquiditySource, + invoiceAmountSats: ULong, + inboundCapacitySats: ULong?, + isGeoBlocked: Boolean, + ): Boolean { + if (source != ReceiveLiquiditySource.SPENDING) return false + if (!needsInboundLiquidity(invoiceAmountSats, inboundCapacitySats)) return false + if ((inboundCapacitySats ?: 0uL) == 0uL) return false + + return !isGeoBlocked + } + + fun needsInboundLiquidity( + invoiceAmountSats: ULong, + inboundCapacitySats: ULong?, + ): Boolean { + val inbound = inboundCapacitySats ?: 0uL + + if (invoiceAmountSats == 0uL) { + return inbound == 0uL + } + + return invoiceAmountSats > inbound + } + + private fun shouldChooseAmount(params: ReceiveAdditionalLiquidityParams): Boolean { + val min = params.minCjitSats ?: 0uL + val max = params.maxCjitAmountSats?.takeIf { it > 0uL } ?: return true + if (params.invoiceAmountSats == 0uL || min == 0uL) return true + + return params.invoiceAmountSats < min || params.invoiceAmountSats > max + } +} diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 8225bf592f..06da1d9d72 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -253,6 +253,9 @@ class BlocktankRepo @Inject constructor( if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() val lspBalance = getDefaultLspBalance(clientBalance = amountSats) + if (!canFitChannelSize(amountSats, lspBalance)) { + throw ServiceError.ChannelSizeExceedsMaximum() + } val channelSizeSat = amountSats + lspBalance val cjitEntry = coreService.blocktank.createCjit( @@ -272,6 +275,38 @@ class BlocktankRepo @Inject constructor( } } + suspend fun canCreateCjit(amountSats: ULong): Result = withContext(bgDispatcher) { + runCatching { + val maxChannelSizeSat = maxChannelSizeSat() ?: return@runCatching true + val lspBalance = getDefaultLspBalance(clientBalance = amountSats) + + return@runCatching amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats + }.onFailure { + Logger.error("Failed to check CJIT limit", it, context = TAG) + } + } + + suspend fun maxCjitAmountSats(): Result = withContext(bgDispatcher) { + runCatching { + val maxChannelSizeSat = maxChannelSizeSat() ?: return@runCatching null + var lowerBound = 0uL + var upperBound = maxChannelSizeSat + + while (lowerBound < upperBound) { + val candidate = lowerBound + (upperBound - lowerBound + 1uL) / 2uL + if (canCreateCjit(candidate).getOrThrow()) { + lowerBound = candidate + } else { + upperBound = candidate - 1uL + } + } + + lowerBound + }.onFailure { + Logger.error("Failed to calculate max CJIT amount", it, context = TAG) + } + } + suspend fun createOrder( spendingBalanceSats: ULong, receivingBalanceSats: ULong = spendingBalanceSats * 2u, @@ -409,6 +444,21 @@ class BlocktankRepo @Inject constructor( return@withContext getDefaultLspBalance(params) } + private suspend fun maxChannelSizeSat(): ULong? { + if (_blocktankState.value.info == null) { + refreshInfo() + } + + return _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } + } + + private fun canFitChannelSize(amountSats: ULong, lspBalance: ULong): Boolean { + val maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } + ?: return true + + return amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats + } + fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { val blocktankInfo = blocktankState.value.info ?: return Result.failure(ServiceError.BlocktankInfoUnavailable()) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 96edad2fb3..0de4226ae2 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -28,7 +28,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.ext.filterOpen +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex @@ -36,8 +36,8 @@ import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.WalletScope -import to.bitkit.models.msatFloorOf import to.bitkit.models.toAccountDerivationPath import to.bitkit.models.toBalance import to.bitkit.models.toDerivationPath @@ -322,7 +322,7 @@ class WalletRepo @Inject constructor( is Event.ChannelReady -> { // Only refresh bolt11 if we can now receive on lightning Logger.debug("refreshBip21ForEvent: $event", context = TAG) - if (lightningRepo.canReceive()) { + if (canCreateLightningInvoice(_walletState.value.bip21AmountSats)) { lightningRepo.createInvoice( amountSats = _walletState.value.bip21AmountSats, description = _walletState.value.bip21Description, @@ -336,7 +336,7 @@ class WalletRepo @Inject constructor( is Event.ChannelClosed -> { // Clear bolt11 if we can no longer receive on lightning Logger.debug("refreshBip21ForEvent: $event", context = TAG) - if (!lightningRepo.canReceive()) { + if (!canCreateLightningInvoice(_walletState.value.bip21AmountSats)) { setBolt11("") updateBip21Url() } @@ -727,8 +727,7 @@ class WalletRepo @Inject constructor( setBip21AmountSats(amountSats) setBip21Description(description) - val canReceive = lightningRepo.canReceive() - if (canReceive) { + if (canCreateLightningInvoice(amountSats)) { lightningRepo.createInvoice(amountSats, description).onSuccess { setBolt11(it) } @@ -748,19 +747,17 @@ class WalletRepo @Inject constructor( } } - suspend fun shouldRequestAdditionalLiquidity(): Result = withContext(bgDispatcher) { - runCatching { - if (coreService.isGeoBlocked()) return@runCatching false - - val channels = lightningRepo.lightningState.value.channels - if (channels.filterOpen().isEmpty()) return@runCatching false - - val inboundBalanceSats = channels.sumOf { msatFloorOf(it.inboundCapacityMsat) } + fun inboundLiquiditySats(): ULong { + return lightningRepo.lightningState.value.channels.calculateRemoteBalance() + } - return@runCatching (_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats - }.onFailure { - Logger.error("shouldRequestAdditionalLiquidity error", it, context = TAG) - } + private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { + val channels = lightningRepo.lightningState.value.channels + return ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = channels.any { it.isChannelReady }, + inboundCapacitySats = channels.calculateRemoteBalance(), + invoiceAmountSats = amountSats, + ) } private suspend fun Scanner.OnChain.extractLightningHash(): String? { diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 46113e75bd..29d7e3631f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -509,17 +510,20 @@ fun ContentView( is Sheet.Receive -> { val walletState by walletViewModel.walletState.collectAsStateWithLifecycle() val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() - ReceiveSheet( - appViewModel = appViewModel, - startRoute = sheet.route, - hardwareWalletId = sheet.hardwareWalletId, - walletState = walletState, - isOffline = connectivityState != ConnectivityState.CONNECTED, - navigateToExternalConnection = { - navController.navigateTo(ExternalConnection()) - appViewModel.hideSheet() - }, - ) + + key(System.identityHashCode(sheet)) { + ReceiveSheet( + appViewModel = appViewModel, + startRoute = sheet.route, + hardwareWalletId = sheet.hardwareWalletId, + walletState = walletState, + isOffline = connectivityState != ConnectivityState.CONNECTED, + navigateToExternalConnection = { + navController.navigateTo(ExternalConnection()) + appViewModel.hideSheet() + }, + ) + } } Sheet.PaymentRequests -> PaymentRequestsSheet( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 5981ce6bdf..f037bbb14a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -43,9 +44,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.models.ReceiveAdditionalLiquidityAction +import to.bitkit.models.ReceiveLiquiditySource +import to.bitkit.models.ReceiveLiquiditySource.AUTO +import to.bitkit.models.ReceiveLiquiditySource.SAVINGS +import to.bitkit.models.ReceiveLiquiditySource.SPENDING import to.bitkit.repositories.CurrencyState +import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.appViewModel import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview @@ -67,6 +75,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.keyboardAsState import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel @@ -75,6 +84,8 @@ import to.bitkit.viewmodels.previewAmountInputViewModel fun EditInvoiceScreen( amountInputViewModel: AmountInputViewModel, walletUiState: WalletState, + lightningState: LightningState, + sourceTab: ReceiveTab, updateInvoice: (ULong?) -> Unit, onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, @@ -85,49 +96,57 @@ fun EditInvoiceScreen( navigateReceiveConfirm: (CjitEntryDetails) -> Unit, onchainOnly: Boolean = false, updateOnchainInvoice: (ULong?) -> Unit = {}, + navigateCjitAmount: () -> Unit, + navigateGeoBlock: () -> Unit, currencies: CurrencyState = LocalCurrencies.current, editInvoiceVM: EditInvoiceVM = hiltViewModel(), ) { + val app = appViewModel ?: return val blocktankVM = blocktankViewModel ?: return var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() + val currentReceiveSats by rememberUpdatedState(amountInputUiState.sats.toULong()) val isLoading by editInvoiceVM.isLoading.collectAsStateWithLifecycle() LaunchedEffect(onchainOnly) { if (onchainOnly) return@LaunchedEffect editInvoiceVM.editInvoiceEffect.collect { effect -> - val receiveSats = amountInputUiState.sats.toULong() + val receiveSats = currentReceiveSats when (effect) { - is EditInvoiceVM.EditInvoiceScreenEffects.NavigateAddLiquidity -> { - updateInvoice(receiveSats) - - if (receiveSats == 0UL) { - onBack() - return@collect - } - - runCatching { blocktankVM.createCjit(receiveSats) }.onSuccess { entry -> - navigateReceiveConfirm( - CjitEntryDetails( - networkFeeSat = entry.networkFeeSat.toLong(), - serviceFeeSat = entry.serviceFeeSat.toLong(), - channelSizeSat = entry.channelSizeSat.toLong(), - feeSat = entry.feeSat.toLong(), - receiveAmountSats = receiveSats.toLong(), - invoice = entry.invoice.request, - ) - ) - }.onFailure { e -> - Logger.error("error creating cjit invoice", e, context = "EditInvoiceScreen") - onBack() + is EditInvoiceVM.EditInvoiceScreenEffects.ApplyReceiveLiquidityAction -> { + when (val action = effect.action) { + ReceiveAdditionalLiquidityAction.None -> { + updateInvoice(receiveSats) + onBack() + } + ReceiveAdditionalLiquidityAction.ChooseAmount -> { + updateInvoice(receiveSats) + navigateCjitAmount() + } + is ReceiveAdditionalLiquidityAction.CreateCjit -> { + runCatching { blocktankVM.createCjit(action.amountSats) }.onSuccess { entry -> + navigateReceiveConfirm( + CjitEntryDetails( + networkFeeSat = entry.networkFeeSat.toLong(), + serviceFeeSat = entry.serviceFeeSat.toLong(), + channelSizeSat = entry.channelSizeSat.toLong(), + feeSat = entry.feeSat.toLong(), + receiveAmountSats = action.amountSats.toLong(), + invoice = entry.invoice.request, + ) + ) + }.onFailure { + Logger.error("Failed to create CJIT invoice", it, context = "EditInvoiceScreen") + if (it !is ServiceError.ChannelSizeExceedsMaximum) { + app.toast(it) + } + navigateCjitAmount() + } + } + ReceiveAdditionalLiquidityAction.GeoBlocked -> navigateGeoBlock() } } - - EditInvoiceVM.EditInvoiceScreenEffects.UpdateInvoice -> { - updateInvoice(receiveSats) - onBack() - } } } } @@ -148,7 +167,13 @@ fun EditInvoiceScreen( } }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = editInvoiceVM::onClickContinue, + onContinueGeneral = { + editInvoiceVM.onClickContinue( + source = sourceTab.toReceiveLiquiditySource(), + amountSats = amountInputUiState.sats.toULong(), + isGeoBlocked = lightningState.isGeoBlocked, + ) + }, onContinueOnchain = { amountSats -> updateOnchainInvoice(amountSats) onBack() @@ -165,6 +190,14 @@ fun EditInvoiceScreen( ) } +private fun ReceiveTab.toReceiveLiquiditySource(): ReceiveLiquiditySource { + return when (this) { + ReceiveTab.SAVINGS -> SAVINGS + ReceiveTab.AUTO -> AUTO + ReceiveTab.SPENDING -> SPENDING + } +} + @Suppress("ViewModelForwarding") @Composable fun EditInvoiceContent( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index de07cf93f7..33adbe38e0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -9,13 +9,19 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.models.ReceiveAdditionalLiquidityAction +import to.bitkit.models.ReceiveAdditionalLiquidityParams +import to.bitkit.models.ReceiveLiquidityDecision +import to.bitkit.models.ReceiveLiquiditySource +import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.WalletRepo import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class EditInvoiceVM @Inject constructor( - val walletRepo: WalletRepo + private val walletRepo: WalletRepo, + private val blocktankRepo: BlocktankRepo, ) : ViewModel() { private val _editInvoiceEffect = MutableSharedFlow(extraBufferCapacity = 1) @@ -30,26 +36,55 @@ class EditInvoiceVM @Inject constructor( ) } - fun onClickContinue() { + fun onClickContinue( + source: ReceiveLiquiditySource, + amountSats: ULong, + isGeoBlocked: Boolean, + ) { viewModelScope.launch { _isLoading.update { true } - walletRepo.shouldRequestAdditionalLiquidity().onSuccess { shouldRequest -> - if (shouldRequest) { - editInvoiceEffect(EditInvoiceScreenEffects.NavigateAddLiquidity) - } else { - editInvoiceEffect(EditInvoiceScreenEffects.UpdateInvoice) - } - }.onFailure { - Logger.warn("Failed to check for liquidity, navigating back to QR screen", context = TAG) - editInvoiceEffect(EditInvoiceScreenEffects.UpdateInvoice) - } + val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, isGeoBlocked) + val action = ReceiveLiquidityDecision.additionalLiquidityAction( + ReceiveAdditionalLiquidityParams( + source = source, + invoiceAmountSats = amountSats, + inboundCapacitySats = walletRepo.inboundLiquiditySats(), + minCjitSats = blocktankRepo.blocktankState.value.minCjitSats?.toULong(), + maxCjitAmountSats = maxCjitAmountSats, + isGeoBlocked = isGeoBlocked, + ) + ) + editInvoiceEffect(EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(action)) _isLoading.update { false } } } + private suspend fun maxCjitAmountSats( + source: ReceiveLiquiditySource, + amountSats: ULong, + isGeoBlocked: Boolean, + ): ULong? { + if (!ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source = source, + invoiceAmountSats = amountSats, + inboundCapacitySats = walletRepo.inboundLiquiditySats(), + isGeoBlocked = isGeoBlocked, + ) + ) { + return null + } + + blocktankRepo.refreshMinCjitSats() + return blocktankRepo.maxCjitAmountSats().getOrElse { + Logger.warn("Failed to calculate max CJIT amount", it, context = TAG) + null + } + } + sealed interface EditInvoiceScreenEffects { - data object UpdateInvoice : EditInvoiceScreenEffects - data object NavigateAddLiquidity : EditInvoiceScreenEffects + data class ApplyReceiveLiquidityAction( + val action: ReceiveAdditionalLiquidityAction, + ) : EditInvoiceScreenEffects } companion object { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index 15ecf20d8f..cbfc6dd641 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 @@ -29,6 +30,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.Toast +import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel @@ -51,10 +54,11 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger +import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel -@Suppress("ViewModelForwarding") +@Suppress("CyclomaticComplexMethod", "ViewModelForwarding") @Composable fun ReceiveAmountScreen( onCjitCreated: (CjitEntryDetails) -> Unit, @@ -63,16 +67,51 @@ fun ReceiveAmountScreen( amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val app = appViewModel ?: return + val context = LocalContext.current val wallet = walletViewModel ?: return val blocktank = blocktankViewModel ?: return val lightningState by wallet.lightningState.collectAsStateWithLifecycle() val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() var isCreatingInvoice by remember { mutableStateOf(false) } + var maxCjitAmountSats by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + fun showMaxExceededToast(max: ULong) { + app.toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__receive_cjit_error_max__title), + description = context.getString(R.string.wallet__receive_cjit_error_max__description) + .replace("{amount}", max.formatToModernDisplay()), + visibilityTime = Toast.VISIBILITY_TIME_SHORT, + testTag = "ReceiveCjitAmountExceededToast", + ) + } + LaunchedEffect(Unit) { blocktank.refreshMinCjitSats() + maxCjitAmountSats = runCatching { blocktank.maxCjitAmountSats() }.getOrNull() + } + + LaunchedEffect(maxCjitAmountSats, amountInputUiState.sats) { + val max = maxCjitAmountSats + amountInputViewModel.setMaxAmount(maxCjitAmountSats?.toLong() ?: 0L) + if (max != null && amountInputUiState.sats.toULong() > max) { + amountInputViewModel.setSats(max.toLong(), currencies) + showMaxExceededToast(max) + } + } + + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> { + val max = maxCjitAmountSats ?: return@collect + amountInputViewModel.setSats(max.toLong(), currencies) + showMaxExceededToast(max) + } + } + } } val minCjitSats by blocktank.minCjitSats.collectAsStateWithLifecycle() @@ -82,12 +121,19 @@ fun ReceiveAmountScreen( minCjitSats = minCjitSats, currencies = currencies, isCreatingInvoice = isCreatingInvoice, - canContinue = amountInputUiState.sats >= (minCjitSats?.toLong() ?: 0), + canContinue = amountInputUiState.sats >= (minCjitSats?.toLong() ?: 0) && + (maxCjitAmountSats?.let { amountInputUiState.sats.toULong() <= it } ?: true), onBack = onBack, onClickMin = { amountInputViewModel.setSats(it, currencies) }, onContinue = { val sats = amountInputUiState.sats scope.launch { + val max = maxCjitAmountSats + if (max != null && sats.toULong() > max) { + amountInputViewModel.setSats(max.toLong(), currencies) + showMaxExceededToast(max) + return@launch + } isCreatingInvoice = true runCatching { require(lightningState.nodeLifecycleState == NodeLifecycleState.Running) { @@ -106,8 +152,13 @@ fun ReceiveAmountScreen( ) ) }.onFailure { e -> - app.toast(e) Logger.error("Failed to create CJIT", e) + if (e.isCjitMaxAmountError()) { + maxCjitAmountSats = runCatching { blocktank.maxCjitAmountSats() }.getOrNull() + maxCjitAmountSats?.let { showMaxExceededToast(it) } + } else { + app.toast(e) + } } isCreatingInvoice = false } @@ -172,7 +223,7 @@ private fun ReceiveAmountContent( color = Colors.White64, ) VerticalSpacer(8.dp) - MoneySSB(sats = minCjitSats.toLong()) + MoneySSB(sats = minCjitSats.toLong(), showSymbol = true) } } ?: CircularProgressIndicator(modifier = Modifier.size(18.dp)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt new file mode 100644 index 0000000000..8b6ee54044 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt @@ -0,0 +1,12 @@ +package to.bitkit.ui.screens.wallets.receive + +import to.bitkit.utils.ServiceError + +internal fun Throwable.isCjitMaxAmountError(): Boolean { + val description = toString() + return this is ServiceError.ChannelSizeExceedsMaximum || + description.contains("Channel size is too big") || + description.contains("channelSizeExceedsMaximum") || + description.contains("maxChannelSizeSat") || + description.contains("channelSizeSat") +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt index 9a098c1f2d..bd2ff3a61f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtils.kt @@ -20,6 +20,7 @@ fun getInvoiceForTab( bolt11: String, cjitInvoice: String?, isNodeRunning: Boolean, + canCreateLightningInvoice: Boolean = true, onchainAddress: String, hardwareAddress: String = "", hardwareAmountSats: ULong? = null, @@ -32,13 +33,14 @@ fun getInvoiceForTab( } ReceiveTab.AUTO -> { - bip21.takeIf { isNodeRunning && containsLightningParameter(bip21) }.orEmpty() + bip21.takeIf { isNodeRunning && canCreateLightningInvoice && containsLightningParameter(bip21) } + ?: removeLightningFromBip21(bip21, onchainAddress) } ReceiveTab.SPENDING -> { // Lightning only: prefer CJIT > bolt11, empty when node is not running cjitInvoice?.takeIf { it.isNotEmpty() && isNodeRunning } - ?: bolt11.takeIf { isNodeRunning }.orEmpty() + ?: bolt11.takeIf { isNodeRunning && canCreateLightningInvoice }.orEmpty() } ReceiveTab.TREZOR -> hardwareAddress.takeIf(String::isNotBlank)?.let { address -> diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 6cf92e3294..bd686d0ca4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -55,8 +55,10 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R +import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.setClipboardText import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.repositories.LightningState import to.bitkit.repositories.WalletState import to.bitkit.ui.components.BodyM @@ -91,7 +93,7 @@ fun ReceiveQrScreen( cjitInvoice: String?, walletState: WalletState, lightningState: LightningState, - onClickEditInvoice: () -> Unit, + onClickEditInvoice: (ReceiveTab) -> Unit, onClickReceiveCjit: () -> Unit, onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, modifier: Modifier = Modifier, @@ -105,22 +107,37 @@ fun ReceiveQrScreen( SetMaxBrightness() val haptic = LocalHapticFeedback.current + val inboundLiquiditySats = lightningState.channels.calculateRemoteBalance() val hasUsableChannels = lightningState.channels.any { it.isChannelReady } + val canCreateLightningInvoice = remember( + hasUsableChannels, + lightningState.channels, + walletState.bip21AmountSats, + ) { + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = hasUsableChannels, + inboundCapacitySats = inboundLiquiditySats, + invoiceAmountSats = walletState.bip21AmountSats, + ) + } var showDetails by remember { mutableStateOf(false) } - val visibleTabs = remember(hasUsableChannels, hardwareWalletId) { + val visibleTabs = remember(canCreateLightningInvoice, cjitInvoice, hardwareWalletId) { buildList { if (hardwareWalletId != null) { add(ReceiveTab.TREZOR) } add(ReceiveTab.SAVINGS) - if (hasUsableChannels) { + if (canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { add(ReceiveTab.AUTO) } add(ReceiveTab.SPENDING) }.toImmutableList() } + val defaultTab = remember(visibleTabs, initialTab) { + initialTab?.takeIf { it in visibleTabs } ?: visibleTabs.defaultReceiveTab() + } val invoicesByTab = remember( visibleTabs, @@ -140,6 +157,7 @@ fun ReceiveQrScreen( bolt11 = walletState.bolt11, cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + canCreateLightningInvoice = canCreateLightningInvoice, onchainAddress = walletState.onchainAddress, hardwareAddress = hardwareReceiveState.address?.address.orEmpty(), hardwareAmountSats = walletState.bip21AmountSats, @@ -161,7 +179,7 @@ fun ReceiveQrScreen( // Calculate current tab based on scroll position for smooth indicator and color updates var selectedTab by remember { - mutableStateOf(initialTab ?: ReceiveTab.SAVINGS) + mutableStateOf(defaultTab) } var hasAppliedInitialTab by remember { mutableStateOf(false) } @@ -175,6 +193,14 @@ fun ReceiveQrScreen( } if (selectedTab !in visibleTabs) { selectedTab = visibleTabs.first() + lazyListState.scrollToItem(0) + } + } + + LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { + if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { + selectedTab = ReceiveTab.SAVINGS + lazyListState.scrollToItem(0) } } @@ -190,8 +216,8 @@ fun ReceiveQrScreen( } // Auto-switch to AUTO tab when it becomes available for the first time - LaunchedEffect(hasUsableChannels) { - if (initialTab == null && hasUsableChannels && visibleTabs.contains(ReceiveTab.AUTO)) { + LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { + if (canCreateLightningInvoice && cjitInvoice.isNullOrEmpty() && visibleTabs.contains(ReceiveTab.AUTO)) { val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO) if (autoIndex != -1) { lazyListState.animateScrollToItem(autoIndex) @@ -218,8 +244,8 @@ fun ReceiveQrScreen( } } - val showingCjitOnboarding = remember(lightningState, cjitInvoice, hasUsableChannels) { - !hasUsableChannels && + val showingCjitOnboarding = remember(lightningState, cjitInvoice, canCreateLightningInvoice) { + !canCreateLightningInvoice && lightningState.nodeLifecycleState.isRunning() && cjitInvoice.isNullOrEmpty() } @@ -252,7 +278,7 @@ fun ReceiveQrScreen( modifier = Modifier.padding(horizontal = 16.dp) ) - VerticalSpacer(24.dp) + VerticalSpacer(16.dp) // Content area (QR or Details) with LazyRow LazyRow( @@ -297,7 +323,7 @@ fun ReceiveQrScreen( walletState = walletState, cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), - onClickEditInvoice = onClickEditInvoice, + onClickEditInvoice = { onClickEditInvoice(tab) }, onClickHardwareEditInvoice = onClickHardwareEditInvoice, hardwareAddress = hardwareReceiveState.address?.address, hardwareInvoice = invoicesByTab[ReceiveTab.TREZOR].orEmpty(), @@ -326,7 +352,7 @@ fun ReceiveQrScreen( onClickEditInvoice = if (tab == ReceiveTab.TREZOR) { onClickHardwareEditInvoice } else if (cjitInvoice.isNullOrEmpty()) { - onClickEditInvoice + { onClickEditInvoice(tab) } } else { onClickReceiveCjit }, @@ -401,7 +427,7 @@ fun ReceiveQrScreen( ) } - BottomButtonVariant.SHOW_DETAILS -> TertiaryButton( + BottomButtonVariant.SHOW_DETAILS -> PrimaryButton( text = stringResource(R.string.wallet__receive_show_details), onClick = { showDetails = true }, enabled = selectedTab != ReceiveTab.TREZOR || hardwareReceiveState.address != null, @@ -418,6 +444,10 @@ fun ReceiveQrScreen( } } +private fun List.defaultReceiveTab(): ReceiveTab { + return if (contains(ReceiveTab.AUTO)) ReceiveTab.AUTO else ReceiveTab.SAVINGS +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReceiveQrView( @@ -449,7 +479,7 @@ private fun ReceiveQrView( VerticalSpacer(16.dp) Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, ) { PrimaryButton( @@ -579,7 +609,10 @@ private fun ReceiveDetailsView( shape = AppShapes.small, modifier = modifier ) { - Column { + Column( + verticalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.padding(32.dp), + ) { when (tab) { ReceiveTab.SAVINGS -> { if (walletState.onchainAddress.isNotEmpty()) { @@ -719,19 +752,18 @@ private fun CopyAddressCard( Column( modifier = Modifier .fillMaxWidth() - .padding(24.dp) ) { Caption13Up(text = title, color = Colors.White64) VerticalSpacer(16.dp) BodyS( - text = (body ?: address).uppercase(), - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, + text = (body ?: address), + maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = testTag?.let { Modifier.testTag(it) } ?: Modifier ) VerticalSpacer(16.dp) Row( - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { PrimaryButton( text = stringResource(R.string.common__edit), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 96b2af06b5..a05dff480c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -73,9 +73,10 @@ fun ReceiveSheet( LaunchedEffect(startRoute) { navController.navigateToReceiveStart(startRoute) } val cjitInvoice = remember { mutableStateOf(null) } - val showCreateCjit = remember { mutableStateOf(false) } val cjitEntryDetails = remember { mutableStateOf(null) } val invoiceEditState = remember { ReceiveInvoiceEditState() } + var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } + var isAdditionalLiquidityAmountEntry by remember { mutableStateOf(false) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() val paymentRequestTargets by appViewModel.eligiblePaymentRequestTargets.collectAsStateWithLifecycle() var paymentRequestDraft by remember { @@ -117,10 +118,6 @@ fun ReceiveSheet( startDestination = rootRoute, ) { composableWithDefaultTransitions { - LaunchedEffect(cjitInvoice.value) { - showCreateCjit.value = !cjitInvoice.value.isNullOrBlank() - } - ReceiveQrScreen( cjitInvoice = cjitInvoice.value, walletState = walletState, @@ -129,15 +126,17 @@ fun ReceiveSheet( if (lightningState.isGeoBlocked) { navController.navigateTo(ReceiveRoute.GeoBlock) } else { - showCreateCjit.value = true + isAdditionalLiquidityAmountEntry = lightningState.channels.isNotEmpty() navController.navigateTo(ReceiveRoute.Amount) } }, onClickEditInvoice = { + editInvoiceSourceTab = it invoiceEditState.beginSoftwareEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, onClickHardwareEditInvoice = { + editInvoiceSourceTab = ReceiveTab.SAVINGS invoiceEditState.beginHardwareEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, @@ -199,7 +198,13 @@ fun ReceiveSheet( ReceiveAmountScreen( onCjitCreated = { entry -> cjitEntryDetails.value = entry - navController.navigateTo(ReceiveRoute.Confirm) + navController.navigateTo( + if (isAdditionalLiquidityAmountEntry) { + ReceiveRoute.ConfirmIncreaseInbound + } else { + ReceiveRoute.Confirm + } + ) }, onBack = { navController.popBackStack() }, ) @@ -286,6 +291,8 @@ fun ReceiveSheet( EditInvoiceScreen( amountInputViewModel = editInvoiceAmountViewModel, walletUiState = walletUiState, + lightningState = lightningState, + sourceTab = editInvoiceSourceTab, onBack = { navController.popBackStack() }, updateInvoice = wallet::updateBip21Invoice, onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, @@ -306,6 +313,11 @@ fun ReceiveSheet( }, onchainOnly = invoiceEditState.isHardwareInvoice, updateOnchainInvoice = wallet::setBip21AmountSats, + navigateCjitAmount = { + isAdditionalLiquidityAmountEntry = true + navController.navigateTo(ReceiveRoute.Amount) + }, + navigateGeoBlock = { navController.navigateTo(ReceiveRoute.GeoBlock) }, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 6cc3b0f973..7b144e2272 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -22,6 +22,7 @@ sealed class ServiceError(message: String) : AppError(message) { class InvalidNodeSigningMessage : ServiceError("Invalid node signing message") class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") + class ChannelSizeExceedsMaximum : ServiceError("Channel size exceeds maximum") class GeoBlocked : ServiceError("Geo blocked user") class GiftClaimPaymentNotReceived : ServiceError("Gift claim payment not received") } diff --git a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt index 2c569999e1..2a973c91bc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt @@ -59,4 +59,8 @@ class BlocktankViewModel @Inject constructor( suspend fun refreshMinCjitSats() { blocktankRepo.refreshMinCjitSats() } + + suspend fun maxCjitAmountSats(): ULong? { + return blocktankRepo.maxCjitAmountSats().getOrThrow() + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 30c8a6ee5c..b84317db7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1253,6 +1253,8 @@ Bitcoin invoice To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. + The amount you can receive with additional liquidity is currently limited to ₿ {amount}. + Maximum exceeded Invoice copied to clipboard Lightning invoice Enable background setup to safely exit Bitkit while your balance is being configured. @@ -1264,10 +1266,10 @@ Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs. Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nBitkit needs to increase the receiving capacity of your spending balance to process this payment. Optional note to payer - Enjoy instant and cheap\ntransactions with friends, family,\nand merchants. + Enjoy instant and cheap bitcoin payments on the Lightning Network. Receive on <accent>spending balance</accent> Show Details - Show QR Code + QR Code Edit Invoice Auto Savings diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt new file mode 100644 index 0000000000..1275ce35b4 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -0,0 +1,181 @@ +package to.bitkit.models + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReceiveLiquidityDecisionTest { + + private val defaultAdditionalLiquidityParams = ReceiveAdditionalLiquidityParams( + source = ReceiveLiquiditySource.SPENDING, + invoiceAmountSats = 10_000u, + inboundCapacitySats = 1_000u, + minCjitSats = 5_000u, + maxCjitAmountSats = 100_000u, + isGeoBlocked = false, + ) + + @Test + fun `lightning invoice requires ready channel`() { + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = false, + inboundCapacitySats = 1_000u, + invoiceAmountSats = null, + ) + ) + } + + @Test + fun `variable lightning invoice requires non-zero inbound liquidity`() { + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = true, + inboundCapacitySats = 0u, + invoiceAmountSats = null, + ) + ) + + assertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = true, + inboundCapacitySats = 1u, + invoiceAmountSats = null, + ) + ) + } + + @Test + fun `fixed lightning invoice requires inbound liquidity covering amount`() { + assertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = true, + inboundCapacitySats = 5_000u, + invoiceAmountSats = 5_000u, + ) + ) + + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels = true, + inboundCapacitySats = 4_999u, + invoiceAmountSats = 5_000u, + ) + ) + } + + @Test + fun `zero inbound does not route to additional CJIT`() { + assertEquals( + ReceiveAdditionalLiquidityAction.None, + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(inboundCapacitySats = 0u) + ) + ) + } + + @Test + fun `savings and auto edits do not route to CJIT`() { + listOf(ReceiveLiquiditySource.SAVINGS, ReceiveLiquiditySource.AUTO).forEach { + assertEquals( + ReceiveAdditionalLiquidityAction.None, + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(source = it) + ) + ) + } + } + + @Test + fun `below CJIT minimum routes to amount picker`() { + assertEquals( + ReceiveAdditionalLiquidityAction.ChooseAmount, + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(invoiceAmountSats = 4_000u) + ) + ) + } + + @Test + fun `at CJIT minimum creates CJIT`() { + assertEquals( + ReceiveAdditionalLiquidityAction.CreateCjit(5_000u), + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(invoiceAmountSats = 5_000u) + ) + ) + } + + @Test + fun `over max CJIT amount routes to amount picker`() { + assertEquals( + ReceiveAdditionalLiquidityAction.ChooseAmount, + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(invoiceAmountSats = 100_001u) + ) + ) + } + + @Test + fun `unknown max CJIT amount routes to amount picker`() { + assertEquals( + ReceiveAdditionalLiquidityAction.ChooseAmount, + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(maxCjitAmountSats = null) + ) + ) + } + + @Test + fun `geo-blocked routes to geo-block screen`() { + assertEquals( + ReceiveAdditionalLiquidityAction.GeoBlocked, + additionalLiquidityAction( + defaultAdditionalLiquidityParams.copy(isGeoBlocked = true) + ) + ) + } + + @Test + fun `CJIT limits are fetched only when additional liquidity can use them`() { + assertFalse( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source = ReceiveLiquiditySource.AUTO, + invoiceAmountSats = 10_000u, + inboundCapacitySats = 1_000u, + isGeoBlocked = false, + ) + ) + + assertFalse( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source = ReceiveLiquiditySource.SPENDING, + invoiceAmountSats = 10_000u, + inboundCapacitySats = 0u, + isGeoBlocked = false, + ) + ) + + assertFalse( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source = ReceiveLiquiditySource.SPENDING, + invoiceAmountSats = 10_000u, + inboundCapacitySats = 1_000u, + isGeoBlocked = true, + ) + ) + + assertTrue( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source = ReceiveLiquiditySource.SPENDING, + invoiceAmountSats = 10_000u, + inboundCapacitySats = 1_000u, + isGeoBlocked = false, + ) + ) + } + + private fun additionalLiquidityAction(params: ReceiveAdditionalLiquidityParams) = + ReceiveLiquidityDecision.additionalLiquidityAction(params) +} diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 393d623f62..95cb4da644 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -492,52 +492,6 @@ class WalletRepoTest : BaseUnitTest() { assertEquals(error, result.exceptionOrNull()) } - @Test - fun `shouldRequestAdditionalLiquidity should return false when geo status is true`() = test { - whenever(coreService.isGeoBlocked()).thenReturn(true) - - val result = sut.shouldRequestAdditionalLiquidity() - - assertTrue(result.isSuccess) - assertFalse(result.getOrThrow()) - } - - @Test - fun `shouldRequestAdditionalLiquidity should return true when amount exceeds inbound capacity`() = test { - whenever(coreService.isGeoBlocked()).thenReturn(false) - whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) - sut.updateBip21Invoice(amountSats = 1000uL) - - val result = sut.shouldRequestAdditionalLiquidity() - - assertTrue(result.isSuccess) - assertTrue(result.getOrThrow()) - } - - @Test - fun `should not request additional liquidity for 0 channels`() = test { - whenever(coreService.isGeoBlocked()).thenReturn(false) - whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) - sut.updateBip21Invoice(amountSats = 1000uL) - - val result = sut.shouldRequestAdditionalLiquidity() - - assertTrue(result.isSuccess) - assertFalse(result.getOrThrow()) - } - - @Test - fun `shouldRequestAdditionalLiquidity should return false when amount is less than inbound capacity`() = test { - whenever(coreService.isGeoBlocked()).thenReturn(false) - whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) - sut.updateBip21Invoice(amountSats = 900uL) - - val result = sut.shouldRequestAdditionalLiquidity() - - assertTrue(result.isSuccess) - assertFalse(result.getOrThrow()) - } - @Test fun `clearBip21State should clear all bip21 related state`() = test { sut.setOnchainAddress(ADDRESS) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index a4fe3426aa..a461929f57 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt @@ -1,11 +1,15 @@ package to.bitkit.ui.screens.wallets.receive import app.cash.turbine.test +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock -import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.models.ReceiveAdditionalLiquidityAction +import to.bitkit.models.ReceiveLiquiditySource +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.BlocktankState import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.screens.wallets.receive.EditInvoiceVM.EditInvoiceScreenEffects @@ -15,57 +19,86 @@ class EditInvoiceVMTest : BaseUnitTest() { private lateinit var sut: EditInvoiceVM private val walletRepo: WalletRepo = mock() + private val blocktankRepo: BlocktankRepo = mock() @Before fun setUp() { - sut = EditInvoiceVM(walletRepo) + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState(minCjitSats = 5_000))) + whenever(walletRepo.inboundLiquiditySats()).thenReturn(1_000u) + sut = EditInvoiceVM(walletRepo, blocktankRepo) } @Test - fun `onClickContinue should emit NavigateAddLiquidity when shouldRequestAdditionalLiquidity returns true`() = test { - // Given - whenever(walletRepo.shouldRequestAdditionalLiquidity()).thenReturn(Result.success(true)) - - // When & Then + fun `onClickContinue should emit none for auto when amount exceeds inbound`() = test { sut.editInvoiceEffect.test { - sut.onClickContinue() - - assertEquals(EditInvoiceScreenEffects.NavigateAddLiquidity, awaitItem()) + sut.onClickContinue( + source = ReceiveLiquiditySource.AUTO, + amountSats = 10_000u, + isGeoBlocked = false, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.None), + awaitItem(), + ) cancelAndIgnoreRemainingEvents() } - - verify(walletRepo).shouldRequestAdditionalLiquidity() } @Test - fun `onClickContinue should emit UpdateInvoice when shouldRequestAdditionalLiquidity returns false`() = test { - // Given - whenever(walletRepo.shouldRequestAdditionalLiquidity()).thenReturn(Result.success(false)) + fun `onClickContinue should emit choose amount for spending below CJIT minimum`() = test { + whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) - // When & Then sut.editInvoiceEffect.test { - sut.onClickContinue() - - assertEquals(EditInvoiceScreenEffects.UpdateInvoice, awaitItem()) + sut.onClickContinue( + source = ReceiveLiquiditySource.SPENDING, + amountSats = 4_000u, + isGeoBlocked = false, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.ChooseAmount), + awaitItem(), + ) cancelAndIgnoreRemainingEvents() } - - verify(walletRepo).shouldRequestAdditionalLiquidity() } @Test - fun `onClickContinue should emit UpdateInvoice when shouldRequestAdditionalLiquidity fails`() = test { - // Given - whenever(walletRepo.shouldRequestAdditionalLiquidity()).thenReturn(Result.failure(Exception("Error"))) + fun `onClickContinue should emit create CJIT for spending amount within limits`() = test { + whenever(blocktankRepo.maxCjitAmountSats()).thenReturn(Result.success(100_000u)) - // When & Then sut.editInvoiceEffect.test { - sut.onClickContinue() - - assertEquals(EditInvoiceScreenEffects.UpdateInvoice, awaitItem()) + sut.onClickContinue( + source = ReceiveLiquiditySource.SPENDING, + amountSats = 10_000u, + isGeoBlocked = false, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction( + ReceiveAdditionalLiquidityAction.CreateCjit(10_000u) + ), + awaitItem(), + ) cancelAndIgnoreRemainingEvents() } + } - verify(walletRepo).shouldRequestAdditionalLiquidity() + @Test + fun `onClickContinue should emit geo blocked without fetching CJIT limits`() = test { + sut.editInvoiceEffect.test { + sut.onClickContinue( + source = ReceiveLiquiditySource.SPENDING, + amountSats = 10_000u, + isGeoBlocked = true, + ) + + assertEquals( + EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(ReceiveAdditionalLiquidityAction.GeoBlocked), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } } } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt index fcea3a0d35..7abca3041b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceUtilsTest.kt @@ -152,7 +152,7 @@ class ReceiveInvoiceUtilsTest { } @Test - fun `getInvoiceForTab AUTO returns empty when has lightning but node not running`() { + fun `getInvoiceForTab AUTO returns onchain BIP21 when has lightning but node not running`() { val bip21 = "bitcoin:$testAddress?amount=0.001&lightning=$testBolt11" val result = getInvoiceForTab( @@ -164,11 +164,11 @@ class ReceiveInvoiceUtilsTest { onchainAddress = testAddress ) - assertEquals("", result) + assertEquals("bitcoin:$testAddress?amount=0.001", result) } @Test - fun `getInvoiceForTab AUTO returns empty when BIP21 has no lightning even if node running`() { + fun `getInvoiceForTab AUTO returns onchain BIP21 when BIP21 has no lightning even if node running`() { val bip21WithoutLightning = "bitcoin:$testAddress?amount=0.001&message=Test" val result = getInvoiceForTab( @@ -180,11 +180,11 @@ class ReceiveInvoiceUtilsTest { onchainAddress = testAddress ) - assertEquals("", result) + assertEquals(bip21WithoutLightning, result) } @Test - fun `getInvoiceForTab AUTO returns empty when no lightning and node not running`() { + fun `getInvoiceForTab AUTO returns onchain BIP21 when no lightning and node not running`() { val bip21WithoutLightning = "bitcoin:$testAddress?amount=0.001&message=Test" val result = getInvoiceForTab( @@ -196,7 +196,24 @@ class ReceiveInvoiceUtilsTest { onchainAddress = testAddress ) - assertEquals("", result) + assertEquals(bip21WithoutLightning, result) + } + + @Test + fun `getInvoiceForTab AUTO returns onchain BIP21 when lightning invoice cannot be created`() { + val bip21 = "bitcoin:$testAddress?amount=0.001&lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.AUTO, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + canCreateLightningInvoice = false, + onchainAddress = testAddress + ) + + assertEquals("bitcoin:$testAddress?amount=0.001", result) } @Test @@ -232,7 +249,7 @@ class ReceiveInvoiceUtilsTest { } @Test - fun `getInvoiceForTab SPENDING returns bolt11 when CJIT unavailable`() { + fun `getInvoiceForTab SPENDING returns bolt11 when CJIT unavailable and lightning invoice can be created`() { val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" val result = getInvoiceForTab( @@ -247,6 +264,23 @@ class ReceiveInvoiceUtilsTest { assertEquals(testBolt11, result) } + @Test + fun `getInvoiceForTab SPENDING returns empty when lightning invoice cannot be created`() { + val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" + + val result = getInvoiceForTab( + tab = ReceiveTab.SPENDING, + bip21 = bip21, + bolt11 = testBolt11, + cjitInvoice = null, + isNodeRunning = true, + canCreateLightningInvoice = false, + onchainAddress = testAddress + ) + + assertEquals("", result) + } + @Test fun `getInvoiceForTab SPENDING returns empty when node not running even with CJIT`() { val bip21 = "bitcoin:$testAddress?lightning=$testBolt11" diff --git a/changelog.d/next/1222.fixed.md b/changelog.d/next/1222.fixed.md new file mode 100644 index 0000000000..d4bdaa40b6 --- /dev/null +++ b/changelog.d/next/1222.fixed.md @@ -0,0 +1 @@ +Receiving over Lightning now correctly falls back to Savings or additional liquidity setup when the requested amount exceeds available inbound capacity. diff --git a/docs/receive-liquidity.md b/docs/receive-liquidity.md new file mode 100644 index 0000000000..a66e5957b1 --- /dev/null +++ b/docs/receive-liquidity.md @@ -0,0 +1,59 @@ +# Receive Liquidity Behavior + +This document describes how the receive flow decides whether to show a normal Lightning invoice or route the user into CJIT liquidity setup. + +## Cases + +- Opening the Receive sheet: + - A new Receive sheet session starts from a fresh tab state. + - If Auto is available, the default tab is Auto. + - If Auto is unavailable, the default tab is Savings. + - Temporary receive-session state, such as selected tab, nested navigation, pending CJIT details, and CJIT invoice QR state, must not survive closing and reopening the Receive sheet. + +- Editing from Savings or Auto: + - Editing sets the amount for the receive request. + - If the edited amount can be received over Lightning, the regenerated Spending invoice also includes that amount. + - If the edited amount cannot be received over Lightning, Auto falls back to the Savings tab and shows the onchain QR instead of routing to CJIT. + - The edit flow does not create CJIT or route to CJIT amount entry. + +- Lightning receive unavailable because there is no ready channel or inbound liquidity is `0`: + - No Lightning invoice is created. + - The normal QR remains Savings/onchain only. + - The Spending tab shows CJIT onboarding. + - Tapping receive spending routes to CJIT amount entry, or the CJIT geo-block screen when geo-blocked. + - Editing from Savings or Auto updates the receive amount and returns to the normal QR; it does not create or route to CJIT. + - When a channel already exists, later CJIT confirmation and learn-more screens use additional-liquidity copy. + +- Ready channel, inbound liquidity greater than `0`, zero/variable amount: + - A Lightning invoice is allowed. + - A zero/variable Lightning invoice is allowed when inbound liquidity is greater than `0`, even though the sender could later choose an amount above the available inbound capacity. + +- Ready channel, fixed amount less than or equal to inbound liquidity: + - A normal BOLT11 invoice is created. + - The unified QR includes Lightning. + - The Spending tab shows the normal Lightning invoice. + +- Ready channel, fixed amount greater than inbound liquidity but below CJIT minimum: + - A normal Lightning invoice is not shown. + - Editing from Spending routes to CJIT amount entry. + - The user must choose at least the minimum CJIT amount. + - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. + +- Ready channel, fixed amount greater than inbound liquidity and at or above CJIT minimum: + - If editing from Spending and the amount can be backed by a CJIT channel without exceeding Blocktank's maximum channel size, the edit flow creates additional CJIT. + - The user gets CJIT confirmation and then a CJIT Lightning invoice QR. + - The CJIT Lightning invoice is an invoice to the LSP and must be shown as Spending-only, not as Auto/unified receive. + - The direct additional CJIT path must not regenerate the normal receive invoice before creating CJIT. + - If editing from Spending and the amount is too large for CJIT, or the maximum cannot be calculated, the edit flow routes to CJIT amount entry. + - The CJIT amount screen enforces the real maximum receivable amount, calculated from `invoiceSat + defaultLspBalance(invoiceSat) <= maxChannelSizeSat`. + - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. + +- Geo-blocked and liquidity is needed: + - The flow routes to the CJIT geo-block screen. + - No CJIT invoice is created. + +## Invariants + +- Auto tab availability and default tab selection are based on whether a normal Lightning invoice can be created for the current receive amount. +- Ready channels alone do not imply Auto availability; fixed receive amounts must also fit within inbound liquidity. +- CJIT min and max limits are only needed when a Spending-origin edit needs additional inbound liquidity and the user is not geo-blocked. From a932f7746c9cf8c76ebbeee8ac7c51127d831e7a Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 3 Sep 2026 12:48:37 +0200 Subject: [PATCH 02/11] fix: refresh cjit max limits --- .../to/bitkit/repositories/BlocktankRepo.kt | 22 ++++++++------- .../bitkit/repositories/BlocktankRepoTest.kt | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 06da1d9d72..15d88bd208 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -252,6 +252,7 @@ class BlocktankRepo @Inject constructor( runCatching { if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() + freshMaxChannelSizeSat() val lspBalance = getDefaultLspBalance(clientBalance = amountSats) if (!canFitChannelSize(amountSats, lspBalance)) { throw ServiceError.ChannelSizeExceedsMaximum() @@ -277,10 +278,8 @@ class BlocktankRepo @Inject constructor( suspend fun canCreateCjit(amountSats: ULong): Result = withContext(bgDispatcher) { runCatching { - val maxChannelSizeSat = maxChannelSizeSat() ?: return@runCatching true - val lspBalance = getDefaultLspBalance(clientBalance = amountSats) - - return@runCatching amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats + val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runCatching true + return@runCatching canCreateCjit(amountSats, maxChannelSizeSat) }.onFailure { Logger.error("Failed to check CJIT limit", it, context = TAG) } @@ -288,13 +287,13 @@ class BlocktankRepo @Inject constructor( suspend fun maxCjitAmountSats(): Result = withContext(bgDispatcher) { runCatching { - val maxChannelSizeSat = maxChannelSizeSat() ?: return@runCatching null + val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runCatching null var lowerBound = 0uL var upperBound = maxChannelSizeSat while (lowerBound < upperBound) { val candidate = lowerBound + (upperBound - lowerBound + 1uL) / 2uL - if (canCreateCjit(candidate).getOrThrow()) { + if (canCreateCjit(candidate, maxChannelSizeSat)) { lowerBound = candidate } else { upperBound = candidate - 1uL @@ -444,14 +443,17 @@ class BlocktankRepo @Inject constructor( return@withContext getDefaultLspBalance(params) } - private suspend fun maxChannelSizeSat(): ULong? { - if (_blocktankState.value.info == null) { - refreshInfo() - } + private suspend fun freshMaxChannelSizeSat(): ULong? { + refreshInfo().getOrThrow() return _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } } + private suspend fun canCreateCjit(amountSats: ULong, maxChannelSizeSat: ULong): Boolean { + val lspBalance = getDefaultLspBalance(clientBalance = amountSats) + return amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats + } + private fun canFitChannelSize(amountSats: ULong, lspBalance: ULong): Boolean { val maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } ?: return true diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 6a7cf2bd69..eee1a44f21 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtInfoOptions import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry import kotlinx.coroutines.flow.MutableStateFlow @@ -15,15 +16,18 @@ import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.OutPoint import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.models.BlocktankBackupV1 +import to.bitkit.models.EUR import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest +import java.math.BigDecimal import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -190,6 +194,22 @@ class BlocktankRepoTest : BaseUnitTest() { } } + @Test + fun `canCreateCjit refreshes max channel size before checking amount`() = test { + sut = createSut() + val staleInfo = btInfo(maxChannelSizeSat = 50_000u) + val freshInfo = btInfo(maxChannelSizeSat = 1_000_000u) + whenever(coreService.blocktank.info(refresh = false)).thenReturn(staleInfo) + whenever(coreService.blocktank.info(refresh = true)).thenReturn(staleInfo, freshInfo) + whenever(currencyRepo.convertFiatToSats(BigDecimal(1), EUR)).thenReturn(Result.success(50_000u)) + + sut.refreshInfo() + val result = sut.canCreateCjit(amountSats = 100_000u) + + assertTrue(result.getOrThrow()) + verify(coreService.blocktank, times(2)).info(refresh = true) + } + @Test fun `getOrder returns failure when refresh fails`() { sut = createSut() @@ -400,6 +420,14 @@ class BlocktankRepoTest : BaseUnitTest() { whenever(state).thenReturn(CJitStateEnum.CREATED) } + private fun btInfo(maxChannelSizeSat: ULong): IBtInfo { + val options = mock() + whenever(options.maxChannelSizeSat).thenReturn(maxChannelSizeSat) + return mock().also { + whenever(it.options).thenReturn(options) + } + } + private suspend fun seedCjitEntries(vararg entries: IcJitEntry) { sut.restoreFromBackup( BlocktankBackupV1(createdAt = 0L, orders = emptyList(), cjitEntries = entries.toList()), From 685f849002aaf6e7d05ca22389f8e737a48d28be Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 3 Sep 2026 13:07:12 +0200 Subject: [PATCH 03/11] fix: refresh cjit limit tests --- .../java/to/bitkit/repositories/BlocktankRepo.kt | 2 ++ .../java/to/bitkit/repositories/BlocktankRepoTest.kt | 12 +++++------- .../java/to/bitkit/repositories/WalletRepoTest.kt | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 15d88bd208..cb39cc596a 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -450,6 +450,8 @@ class BlocktankRepo @Inject constructor( } private suspend fun canCreateCjit(amountSats: ULong, maxChannelSizeSat: ULong): Boolean { + if (amountSats > maxChannelSizeSat) return false + val lspBalance = getDefaultLspBalance(clientBalance = amountSats) return amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats } diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index eee1a44f21..54235af93d 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -23,12 +23,11 @@ import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.models.BlocktankBackupV1 -import to.bitkit.models.EUR import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest -import java.math.BigDecimal import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -197,17 +196,16 @@ class BlocktankRepoTest : BaseUnitTest() { @Test fun `canCreateCjit refreshes max channel size before checking amount`() = test { sut = createSut() - val staleInfo = btInfo(maxChannelSizeSat = 50_000u) - val freshInfo = btInfo(maxChannelSizeSat = 1_000_000u) + val staleInfo = btInfo(maxChannelSizeSat = 1_000_000u) + val freshInfo = btInfo(maxChannelSizeSat = 50_000u) whenever(coreService.blocktank.info(refresh = false)).thenReturn(staleInfo) whenever(coreService.blocktank.info(refresh = true)).thenReturn(staleInfo, freshInfo) - whenever(currencyRepo.convertFiatToSats(BigDecimal(1), EUR)).thenReturn(Result.success(50_000u)) sut.refreshInfo() val result = sut.canCreateCjit(amountSats = 100_000u) - assertTrue(result.getOrThrow()) - verify(coreService.blocktank, times(2)).info(refresh = true) + assertFalse(result.getOrThrow()) + verify(coreService.blocktank, times(3)).info(refresh = true) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 95cb4da644..c141eff376 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -301,6 +301,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `updateBip21Invoice should create bolt11 when node can receive`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) sut.updateBip21Invoice(amountSats = SATS, description = "test").let { result -> @@ -529,6 +530,7 @@ class WalletRepoTest : BaseUnitTest() { sut.setBip21AmountSats(SATS) sut.setBip21Description(testDescription) whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) sut.refreshBip21ForEvent(channelReady) @@ -572,6 +574,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21ForEvent ChannelClosed should not clear bolt11 when can still receive`() = test { sut.setBolt11(INVOICE) whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) sut.refreshBip21ForEvent( Event.ChannelClosed( @@ -737,6 +740,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `refreshBip21 should create a fresh invoice after PaymentReceived invalidates the old one`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) From d9c603ad749381827898cdafba2dd6fb8dde540a Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 4 Sep 2026 14:54:34 +0200 Subject: [PATCH 04/11] fix: address cjit review nits --- .../to/bitkit/repositories/BlocktankRepo.kt | 43 ++++++++++++++----- .../wallets/receive/EditInvoiceScreen.kt | 4 +- .../wallets/receive/ReceiveAmountScreen.kt | 10 +++-- .../wallets/receive/ReceiveCjitErrors.kt | 12 ------ .../wallets/receive/ReceiveQrScreen.kt | 2 +- 5 files changed, 43 insertions(+), 28 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index cb39cc596a..566e845048 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -54,8 +54,10 @@ import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.EUR +import to.bitkit.models.safe import to.bitkit.models.msatCeilOf import to.bitkit.services.CoreService import to.bitkit.services.LightningService @@ -249,7 +251,7 @@ class BlocktankRepo @Inject constructor( amountSats: ULong, description: String = "", ): Result = withContext(bgDispatcher) { - runCatching { + runSuspendCatching { if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() freshMaxChannelSizeSat() @@ -257,7 +259,7 @@ class BlocktankRepo @Inject constructor( if (!canFitChannelSize(amountSats, lspBalance)) { throw ServiceError.ChannelSizeExceedsMaximum() } - val channelSizeSat = amountSats + lspBalance + val channelSizeSat = amountSats.safe() + lspBalance.safe() val cjitEntry = coreService.blocktank.createCjit( channelSizeSat = channelSizeSat, @@ -270,29 +272,34 @@ class BlocktankRepo @Inject constructor( repoScope.launch { refreshOrders() } - return@runCatching cjitEntry - }.onFailure { + return@runSuspendCatching cjitEntry + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(it.toCjitError()) }, + ).onFailure { Logger.error("Failed to create CJIT", it, context = TAG) } } suspend fun canCreateCjit(amountSats: ULong): Result = withContext(bgDispatcher) { - runCatching { - val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runCatching true - return@runCatching canCreateCjit(amountSats, maxChannelSizeSat) + runSuspendCatching { + val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching true + return@runSuspendCatching canCreateCjit(amountSats, maxChannelSizeSat) }.onFailure { Logger.error("Failed to check CJIT limit", it, context = TAG) } } suspend fun maxCjitAmountSats(): Result = withContext(bgDispatcher) { - runCatching { - val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runCatching null + runSuspendCatching { + val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching null var lowerBound = 0uL var upperBound = maxChannelSizeSat while (lowerBound < upperBound) { - val candidate = lowerBound + (upperBound - lowerBound + 1uL) / 2uL + val distance = upperBound.safe() - lowerBound.safe() + val step = (distance.safe() + 1uL.safe()) / 2uL + val candidate = lowerBound.safe() + step.safe() if (canCreateCjit(candidate, maxChannelSizeSat)) { lowerBound = candidate } else { @@ -463,6 +470,22 @@ class BlocktankRepo @Inject constructor( return amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats } + private fun Throwable.toCjitError(): Throwable { + if (this is ServiceError.ChannelSizeExceedsMaximum) return this + + val description = toString() + return if ( + description.contains("Channel size is too big") || + description.contains("channelSizeExceedsMaximum") || + description.contains("maxChannelSizeSat") || + description.contains("channelSizeSat") + ) { + ServiceError.ChannelSizeExceedsMaximum() + } else { + this + } + } + fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { val blocktankInfo = blocktankState.value.info ?: return Result.failure(ServiceError.BlocktankInfoUnavailable()) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index f037bbb14a..d1eb27e4f3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -44,6 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveLiquiditySource import to.bitkit.models.ReceiveLiquiditySource.AUTO @@ -125,7 +126,7 @@ fun EditInvoiceScreen( navigateCjitAmount() } is ReceiveAdditionalLiquidityAction.CreateCjit -> { - runCatching { blocktankVM.createCjit(action.amountSats) }.onSuccess { entry -> + runSuspendCatching { blocktankVM.createCjit(action.amountSats) }.onSuccess { entry -> navigateReceiveConfirm( CjitEntryDetails( networkFeeSat = entry.networkFeeSat.toLong(), @@ -195,6 +196,7 @@ private fun ReceiveTab.toReceiveLiquiditySource(): ReceiveLiquiditySource { ReceiveTab.SAVINGS -> SAVINGS ReceiveTab.AUTO -> AUTO ReceiveTab.SPENDING -> SPENDING + ReceiveTab.TREZOR -> SAVINGS } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index cbfc6dd641..2214fd8412 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -29,6 +29,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast import to.bitkit.models.formatToModernDisplay @@ -54,6 +55,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel @@ -90,7 +92,7 @@ fun ReceiveAmountScreen( LaunchedEffect(Unit) { blocktank.refreshMinCjitSats() - maxCjitAmountSats = runCatching { blocktank.maxCjitAmountSats() }.getOrNull() + maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() } LaunchedEffect(maxCjitAmountSats, amountInputUiState.sats) { @@ -135,7 +137,7 @@ fun ReceiveAmountScreen( return@launch } isCreatingInvoice = true - runCatching { + runSuspendCatching { require(lightningState.nodeLifecycleState == NodeLifecycleState.Running) { "Should not be able to land on this screen if the node is not running." } @@ -153,8 +155,8 @@ fun ReceiveAmountScreen( ) }.onFailure { e -> Logger.error("Failed to create CJIT", e) - if (e.isCjitMaxAmountError()) { - maxCjitAmountSats = runCatching { blocktank.maxCjitAmountSats() }.getOrNull() + if (e is ServiceError.ChannelSizeExceedsMaximum) { + maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() maxCjitAmountSats?.let { showMaxExceededToast(it) } } else { app.toast(e) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt deleted file mode 100644 index 8b6ee54044..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrors.kt +++ /dev/null @@ -1,12 +0,0 @@ -package to.bitkit.ui.screens.wallets.receive - -import to.bitkit.utils.ServiceError - -internal fun Throwable.isCjitMaxAmountError(): Boolean { - val description = toString() - return this is ServiceError.ChannelSizeExceedsMaximum || - description.contains("Channel size is too big") || - description.contains("channelSizeExceedsMaximum") || - description.contains("maxChannelSizeSat") || - description.contains("channelSizeSat") -} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index bd686d0ca4..bbfb6bf484 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -95,7 +95,7 @@ fun ReceiveQrScreen( lightningState: LightningState, onClickEditInvoice: (ReceiveTab) -> Unit, onClickReceiveCjit: () -> Unit, - onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, + onClickHardwareEditInvoice: () -> Unit = { onClickEditInvoice(ReceiveTab.TREZOR) }, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, hardwareWalletId: String? = null, From ef9067646cb51223e411fa693d791c3d43ec3a7a Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 14:33:03 +0200 Subject: [PATCH 05/11] fix: address receive cjit review --- .../to/bitkit/repositories/BlocktankRepo.kt | 61 ++++++++++--------- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +- .../java/to/bitkit/ui/components/SheetHost.kt | 2 + .../screens/wallets/receive/EditInvoiceVM.kt | 10 +-- .../wallets/receive/ReceiveQrScreen.kt | 14 +++-- app/src/main/res/values/strings.xml | 4 +- .../bitkit/repositories/BlocktankRepoTest.kt | 46 ++++++++++++-- .../test/java/to/bitkit/ui/ContentViewTest.kt | 11 ++++ 8 files changed, 101 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 566e845048..5a30fdb06d 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -57,8 +57,8 @@ import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.EUR -import to.bitkit.models.safe import to.bitkit.models.msatCeilOf +import to.bitkit.models.safe import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.utils.Logger @@ -254,7 +254,10 @@ class BlocktankRepo @Inject constructor( runSuspendCatching { if (coreService.isGeoBlocked()) throw ServiceError.GeoBlocked() val nodeId = lightningService.nodeId ?: throw ServiceError.NodeNotStarted() - freshMaxChannelSizeSat() + val maxChannelSizeSat = freshMaxChannelSizeSat() + if (maxChannelSizeSat != null && amountSats > maxChannelSizeSat) { + throw ServiceError.ChannelSizeExceedsMaximum() + } val lspBalance = getDefaultLspBalance(clientBalance = amountSats) if (!canFitChannelSize(amountSats, lspBalance)) { throw ServiceError.ChannelSizeExceedsMaximum() @@ -281,15 +284,6 @@ class BlocktankRepo @Inject constructor( } } - suspend fun canCreateCjit(amountSats: ULong): Result = withContext(bgDispatcher) { - runSuspendCatching { - val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching true - return@runSuspendCatching canCreateCjit(amountSats, maxChannelSizeSat) - }.onFailure { - Logger.error("Failed to check CJIT limit", it, context = TAG) - } - } - suspend fun maxCjitAmountSats(): Result = withContext(bgDispatcher) { runSuspendCatching { val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching null @@ -451,7 +445,7 @@ class BlocktankRepo @Inject constructor( } private suspend fun freshMaxChannelSizeSat(): ULong? { - refreshInfo().getOrThrow() + refreshInfo() return _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } } @@ -460,30 +454,18 @@ class BlocktankRepo @Inject constructor( if (amountSats > maxChannelSizeSat) return false val lspBalance = getDefaultLspBalance(clientBalance = amountSats) - return amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats + val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe() + return lspBalance <= remainingCapacity } private fun canFitChannelSize(amountSats: ULong, lspBalance: ULong): Boolean { val maxChannelSizeSat = _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } ?: return true - return amountSats <= maxChannelSizeSat && lspBalance <= maxChannelSizeSat - amountSats - } + if (amountSats > maxChannelSizeSat) return false - private fun Throwable.toCjitError(): Throwable { - if (this is ServiceError.ChannelSizeExceedsMaximum) return this - - val description = toString() - return if ( - description.contains("Channel size is too big") || - description.contains("channelSizeExceedsMaximum") || - description.contains("maxChannelSizeSat") || - description.contains("channelSizeSat") - ) { - ServiceError.ChannelSizeExceedsMaximum() - } else { - this - } + val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe() + return lspBalance <= remainingCapacity } fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { @@ -677,6 +659,27 @@ class BlocktankRepo @Inject constructor( } } +internal fun Throwable.toCjitError(): Throwable { + if (this is ServiceError.ChannelSizeExceedsMaximum) return this + + return if (isMaxChannelSizeError()) { + ServiceError.ChannelSizeExceedsMaximum() + } else { + this + } +} + +private fun Throwable.isMaxChannelSizeError(): Boolean { + val description = toString() + val maximumErrors = listOf( + "Channel size is too big", + "channelSizeExceedsMaximum", + "maxChannelSizeSat", + "capacity is above our capacity limit", + ) + return maximumErrors.any { description.contains(it, ignoreCase = true) } +} + @Stable data class BlocktankState( val orders: ImmutableList = persistentListOf(), diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 29d7e3631f..3ebba1bb58 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -511,7 +511,7 @@ fun ContentView( val walletState by walletViewModel.walletState.collectAsStateWithLifecycle() val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() - key(System.identityHashCode(sheet)) { + key(receiveSheetPresentationKey(sheet)) { ReceiveSheet( appViewModel = appViewModel, startRoute = sheet.route, @@ -1970,6 +1970,8 @@ fun NavController.navigateToTransferSpendingStart( internal fun shouldDismissSheetForScreenLink(handled: Boolean, currentSheet: Sheet?): Boolean = handled && currentSheet != null +internal fun receiveSheetPresentationKey(sheet: Sheet.Receive): String = sheet.presentationId + internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) { TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 4d0443ed30..5cfb1f3432 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -42,6 +42,7 @@ import to.bitkit.ui.sheets.WidgetsRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.Colors +import java.util.UUID enum class SheetSize { LARGE, MEDIUM, COMPACT, SMALL, CALENDAR; } @@ -61,6 +62,7 @@ sealed interface Sheet { data class Receive( val route: ReceiveRoute = ReceiveRoute.QR, val hardwareWalletId: String? = null, + val presentationId: String = UUID.randomUUID().toString(), ) : Sheet data object PaymentRequests : Sheet data class Pin(val route: PinRoute = PinRoute.Prompt()) : Sheet diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 33adbe38e0..00befe6484 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -15,7 +15,6 @@ import to.bitkit.models.ReceiveLiquidityDecision import to.bitkit.models.ReceiveLiquiditySource import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel @@ -75,10 +74,7 @@ class EditInvoiceVM @Inject constructor( } blocktankRepo.refreshMinCjitSats() - return blocktankRepo.maxCjitAmountSats().getOrElse { - Logger.warn("Failed to calculate max CJIT amount", it, context = TAG) - null - } + return blocktankRepo.maxCjitAmountSats().getOrNull() } sealed interface EditInvoiceScreenEffects { @@ -86,8 +82,4 @@ class EditInvoiceVM @Inject constructor( val action: ReceiveAdditionalLiquidityAction, ) : EditInvoiceScreenEffects } - - companion object { - const val TAG = "EditInvoiceVM" - } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index bbfb6bf484..05c12cf0b5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -169,7 +169,7 @@ fun ReceiveQrScreen( // LazyRow state with snap behavior val scope = rememberCoroutineScope() val lazyListState = rememberLazyListState( - initialFirstVisibleItemIndex = visibleTabs.indexOf(initialTab ?: ReceiveTab.SAVINGS).coerceAtLeast(0), + initialFirstVisibleItemIndex = visibleTabs.indexOf(defaultTab).coerceAtLeast(0), ) val snapBehavior = rememberSnapFlingBehavior( @@ -192,15 +192,16 @@ fun ReceiveQrScreen( } } if (selectedTab !in visibleTabs) { - selectedTab = visibleTabs.first() - lazyListState.scrollToItem(0) + val fallbackTab = visibleTabs.defaultReceiveTab() + selectedTab = fallbackTab + lazyListState.scrollToItem(visibleTabs.indexOf(fallbackTab).coerceAtLeast(0)) } } LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { selectedTab = ReceiveTab.SAVINGS - lazyListState.scrollToItem(0) + lazyListState.scrollToItem(visibleTabs.indexOf(ReceiveTab.SAVINGS).coerceAtLeast(0)) } } @@ -217,7 +218,8 @@ fun ReceiveQrScreen( // Auto-switch to AUTO tab when it becomes available for the first time LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { - if (canCreateLightningInvoice && cjitInvoice.isNullOrEmpty() && visibleTabs.contains(ReceiveTab.AUTO)) { + val shouldAutoSwitch = initialTab == null && canCreateLightningInvoice && cjitInvoice.isNullOrEmpty() + if (shouldAutoSwitch && visibleTabs.contains(ReceiveTab.AUTO)) { val autoIndex = visibleTabs.indexOf(ReceiveTab.AUTO) if (autoIndex != -1) { lazyListState.animateScrollToItem(autoIndex) @@ -611,7 +613,7 @@ private fun ReceiveDetailsView( ) { Column( verticalArrangement = Arrangement.spacedBy(32.dp), - modifier = Modifier.padding(32.dp), + modifier = Modifier.padding(32.dp) ) { when (tab) { ReceiveTab.SAVINGS -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b84317db7f..0c39fe14c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1251,10 +1251,10 @@ Receive Lightning funds Receive Bitcoin Bitcoin invoice + The maximum you can receive to your spending balance right now is ₿ {amount}. + Receiving Capacity Maximum To receive more instant Bitcoin, Bitkit has to increase your liquidity. A <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted from the amount you specified. To set up your spending balance, a <accent>{networkFee}</accent> network fee and <accent>{serviceFee}</accent> service provider fee will be deducted. - The amount you can receive with additional liquidity is currently limited to ₿ {amount}. - Maximum exceeded Invoice copied to clipboard Lightning invoice Enable background setup to safely exit Bitkit while your balance is being configured. diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 54235af93d..909a8b7d4b 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -26,8 +26,9 @@ import to.bitkit.models.BlocktankBackupV1 import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError import kotlin.test.assertEquals -import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -194,20 +195,57 @@ class BlocktankRepoTest : BaseUnitTest() { } @Test - fun `canCreateCjit refreshes max channel size before checking amount`() = test { + fun `createCjit refreshes max channel size before checking amount`() = test { sut = createSut() val staleInfo = btInfo(maxChannelSizeSat = 1_000_000u) val freshInfo = btInfo(maxChannelSizeSat = 50_000u) whenever(coreService.blocktank.info(refresh = false)).thenReturn(staleInfo) whenever(coreService.blocktank.info(refresh = true)).thenReturn(staleInfo, freshInfo) + whenever(coreService.isGeoBlocked()).thenReturn(false) + whenever(lightningService.nodeId).thenReturn("node-id") sut.refreshInfo() - val result = sut.canCreateCjit(amountSats = 100_000u) + val result = sut.createCjit(amountSats = 100_000u) - assertFalse(result.getOrThrow()) + assertIs(result.exceptionOrNull()) verify(coreService.blocktank, times(3)).info(refresh = true) } + @Test + fun `createCjit uses cached max channel size when fresh info refresh fails`() = test { + sut = createSut() + val cachedInfo = btInfo(maxChannelSizeSat = 1_000_000u) + whenever(coreService.blocktank.info(refresh = false)).thenReturn(cachedInfo) + whenever(coreService.blocktank.info(refresh = true)).thenReturn(cachedInfo) + .thenThrow(RuntimeException("Network error")) + whenever(coreService.isGeoBlocked()).thenReturn(false) + whenever(lightningService.nodeId).thenReturn("node-id") + + sut.refreshInfo() + val result = sut.createCjit(amountSats = 1_000_001uL) + + assertIs(result.exceptionOrNull()) + verify(coreService.blocktank, times(3)).info(refresh = true) + } + + @Test + fun `toCjitError maps node capacity limit to max channel size error`() { + val error = RuntimeException("Node capacity is above our capacity limit.") + + val result = error.toCjitError() + + assertIs(result) + } + + @Test + fun `toCjitError does not map generic channel size field error to max channel size error`() { + val error = RuntimeException("channelSizeSat must be above minimum") + + val result = error.toCjitError() + + assertEquals(error, result) + } + @Test fun `getOrder returns failure when refresh fails`() { sut = createSut() diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 3ca936d620..df3a1e3891 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -5,6 +5,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.components.Sheet +import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.viewmodels.TransferEffect import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -55,4 +56,14 @@ class ContentViewTest { assertFalse(result) } + + @Test + fun `receive presentation key changes only between sheet presentations`() { + val sheet = Sheet.Receive() + val samePresentation = sheet.copy(route = ReceiveRoute.Amount) + val nextPresentation = Sheet.Receive() + + assertEquals(receiveSheetPresentationKey(sheet), receiveSheetPresentationKey(samePresentation)) + assertFalse(receiveSheetPresentationKey(sheet) == receiveSheetPresentationKey(nextPresentation)) + } } From 47b655024b981f13b74fd04072af956b376cdffb Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 14:46:00 +0200 Subject: [PATCH 06/11] test: fix receive deeplink assertions --- .../java/to/bitkit/ui/utils/SheetDeepLinksTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt b/app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt index 2ae94e8d33..25dfeb981c 100644 --- a/app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt +++ b/app/src/testDebug/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt @@ -14,6 +14,7 @@ import to.bitkit.ui.sheets.WidgetsRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import kotlin.reflect.KClass import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -59,7 +60,8 @@ class SheetDeepLinksTest : BaseUnitTest() { val widgets = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/widgets")) assertEquals(Sheet.Send(SendRoute.Recipient), send) - assertEquals(Sheet.Receive(ReceiveRoute.QR), receive) + assertIs(receive) + assertEquals(ReceiveRoute.QR, receive.route) assertEquals(Sheet.Widgets(WidgetsRoute.Gallery), widgets) } @@ -78,7 +80,8 @@ class SheetDeepLinksTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Amount), amount) assertEquals(Sheet.Widgets(WidgetsRoute.PriceEdit), priceEdit) - assertEquals(Sheet.Receive(ReceiveRoute.EditInvoice), editInvoice) + assertIs(editInvoice) + assertEquals(ReceiveRoute.EditInvoice, editInvoice.route) } @Test From ebb48d395bb5f7efa40b17e0d852f8b6208f74bf Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 15:07:48 +0200 Subject: [PATCH 07/11] fix: preserve trezor receive tab --- .../wallets/receive/ReceiveQrScreen.kt | 16 ++++--- .../screens/wallets/receive/ReceiveSheet.kt | 11 +++-- .../receive/ReceiveInvoiceEditStateTest.kt | 43 +++++++++++++++++++ docs/receive-liquidity.md | 5 +++ 4 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index 05c12cf0b5..c11221459b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -182,13 +182,18 @@ fun ReceiveQrScreen( mutableStateOf(defaultTab) } var hasAppliedInitialTab by remember { mutableStateOf(false) } + var appliedInitialTab by remember { mutableStateOf(null) } LaunchedEffect(visibleTabs, initialTab) { - if (!hasAppliedInitialTab) { + val requestedTab = initialTab?.takeIf { it in visibleTabs } + val shouldApplyInitialTab = !hasAppliedInitialTab || requestedTab != null && requestedTab != appliedInitialTab + if (shouldApplyInitialTab) { hasAppliedInitialTab = true - initialTab?.takeIf { it in visibleTabs }?.let { requestedTab -> - selectedTab = requestedTab - lazyListState.scrollToItem(visibleTabs.indexOf(requestedTab)) + appliedInitialTab = requestedTab + requestedTab?.let { + selectedTab = it + lazyListState.scrollToItem(visibleTabs.indexOf(it)) + return@LaunchedEffect } } if (selectedTab !in visibleTabs) { @@ -198,7 +203,8 @@ fun ReceiveQrScreen( } } - LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { + LaunchedEffect(canCreateLightningInvoice, cjitInvoice, initialTab) { + if (initialTab == ReceiveTab.TREZOR) return@LaunchedEffect if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { selectedTab = ReceiveTab.SAVINGS lazyListState.scrollToItem(visibleTabs.indexOf(ReceiveTab.SAVINGS).coerceAtLeast(0)) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index a05dff480c..c547d8b21b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -132,11 +132,11 @@ fun ReceiveSheet( }, onClickEditInvoice = { editInvoiceSourceTab = it - invoiceEditState.beginSoftwareEdit() + invoiceEditState.beginSoftwareEdit(it) navController.navigateTo(ReceiveRoute.EditInvoice) }, onClickHardwareEditInvoice = { - editInvoiceSourceTab = ReceiveTab.SAVINGS + editInvoiceSourceTab = ReceiveTab.TREZOR invoiceEditState.beginHardwareEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, @@ -356,17 +356,20 @@ fun ReceiveSheet( internal class ReceiveInvoiceEditState { var isHardwareInvoice by mutableStateOf(false) private set + private var returnTab by mutableStateOf(null) - fun beginSoftwareEdit() { + fun beginSoftwareEdit(sourceTab: ReceiveTab) { isHardwareInvoice = false + returnTab = sourceTab } fun beginHardwareEdit() { isHardwareInvoice = true + returnTab = ReceiveTab.TREZOR } fun initialTab(hardwareWalletId: String?): ReceiveTab? = - ReceiveTab.TREZOR.takeIf { hardwareWalletId != null || isHardwareInvoice } + returnTab ?: ReceiveTab.TREZOR.takeIf { hardwareWalletId != null } } @Composable diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt new file mode 100644 index 0000000000..629058fa48 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/ReceiveInvoiceEditStateTest.kt @@ -0,0 +1,43 @@ +package to.bitkit.ui.screens.wallets.receive + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReceiveInvoiceEditStateTest { + @Test + fun `hardware wallet receive starts on trezor before editing`() { + val state = ReceiveInvoiceEditState() + + assertEquals(ReceiveTab.TREZOR, state.initialTab(hardwareWalletId = "trezor-1")) + } + + @Test + fun `software edit returns to source tab when hardware wallet is active`() { + val state = ReceiveInvoiceEditState() + + state.beginSoftwareEdit(ReceiveTab.SAVINGS) + + assertEquals(ReceiveTab.SAVINGS, state.initialTab(hardwareWalletId = "trezor-1")) + assertFalse(state.isHardwareInvoice) + } + + @Test + fun `hardware edit returns to trezor tab`() { + val state = ReceiveInvoiceEditState() + + state.beginHardwareEdit() + + assertEquals(ReceiveTab.TREZOR, state.initialTab(hardwareWalletId = null)) + assertTrue(state.isHardwareInvoice) + } + + @Test + fun `normal receive has no initial tab before editing`() { + val state = ReceiveInvoiceEditState() + + assertNull(state.initialTab(hardwareWalletId = null)) + } +} diff --git a/docs/receive-liquidity.md b/docs/receive-liquidity.md index a66e5957b1..86d3b92710 100644 --- a/docs/receive-liquidity.md +++ b/docs/receive-liquidity.md @@ -16,6 +16,11 @@ This document describes how the receive flow decides whether to show a normal Li - If the edited amount cannot be received over Lightning, Auto falls back to the Savings tab and shows the onchain QR instead of routing to CJIT. - The edit flow does not create CJIT or route to CJIT amount entry. +- Editing from a hardware receive tab: + - Editing sets the amount for the hardware/onchain receive request. + - Returning from the edit flow preserves the hardware receive tab when the edit originated there. + - Editing from Savings or Auto while a hardware wallet is available still returns to the source tab, not the hardware tab. + - Lightning receive unavailable because there is no ready channel or inbound liquidity is `0`: - No Lightning invoice is created. - The normal QR remains Savings/onchain only. From 7fca1061a2f93e40cf556c2e87c3e4e37a35c6b2 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 16:59:52 +0200 Subject: [PATCH 08/11] fix: refresh receive channel state --- .../java/to/bitkit/repositories/WalletRepo.kt | 18 +++++++-- .../wallets/receive/ReceiveAmountScreen.kt | 2 +- .../to/bitkit/repositories/WalletRepoTest.kt | 37 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 0de4226ae2..c2183902a3 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Bolt11Invoice +import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.WordCount import to.bitkit.async.appScope @@ -318,6 +319,13 @@ class WalletRepo @Inject constructor( settledReceiveInvoice: SettledReceiveInvoice? = null, settledReceiveAddress: SettledReceiveAddress? = null, ) = withContext(bgDispatcher) { + when (event) { + is Event.ChannelReady, + is Event.ChannelClosed, + -> lightningRepo.syncState() + else -> Unit + } + when (event) { is Event.ChannelReady -> { // Only refresh bolt11 if we can now receive on lightning @@ -748,18 +756,22 @@ class WalletRepo @Inject constructor( } fun inboundLiquiditySats(): ULong { - return lightningRepo.lightningState.value.channels.calculateRemoteBalance() + return currentChannels().calculateRemoteBalance() } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { - val channels = lightningRepo.lightningState.value.channels + val channels = currentChannels() return ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = channels.any { it.isChannelReady }, + hasReadyChannels = channels.any { it.isUsable }, inboundCapacitySats = channels.calculateRemoteBalance(), invoiceAmountSats = amountSats, ) } + private fun currentChannels(): List { + return lightningRepo.getChannels() ?: lightningRepo.lightningState.value.channels + } + private suspend fun Scanner.OnChain.extractLightningHash(): String? { val lightningInvoice: String = this.invoice.params?.get("lightning") ?: return null diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index 2214fd8412..b075a9a3b2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -157,7 +157,7 @@ fun ReceiveAmountScreen( Logger.error("Failed to create CJIT", e) if (e is ServiceError.ChannelSizeExceedsMaximum) { maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() - maxCjitAmountSats?.let { showMaxExceededToast(it) } + maxCjitAmountSats?.let { showMaxExceededToast(it) } ?: app.toast(e) } else { app.toast(e) } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c141eff376..aedf35c201 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -70,10 +70,12 @@ class WalletRepoTest : BaseUnitTest() { mock { on { inboundCapacityMsat } doReturn 500_000u on { isChannelReady } doReturn true + on { isUsable } doReturn true }, mock { on { inboundCapacityMsat } doReturn 500_000u on { isChannelReady } doReturn true + on { isUsable } doReturn true } ).toImmutableList() private val channelReady = Event.ChannelReady( @@ -302,6 +304,7 @@ class WalletRepoTest : BaseUnitTest() { fun `updateBip21Invoice should create bolt11 when node can receive`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) + whenever(lightningRepo.getChannels()).thenReturn(channels) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) sut.updateBip21Invoice(amountSats = SATS, description = "test").let { result -> @@ -531,6 +534,7 @@ class WalletRepoTest : BaseUnitTest() { sut.setBip21Description(testDescription) whenever(lightningRepo.canReceive()).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) + whenever(lightningRepo.getChannels()).thenReturn(channels) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) sut.refreshBip21ForEvent(channelReady) @@ -550,6 +554,37 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) } + @Test + fun `refreshBip21ForEvent ChannelClosed should clear bolt11 when live channels are gone`() = test { + sut.setBolt11(INVOICE) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + + sut.refreshBip21ForEvent( + Event.ChannelClosed( + channelId = "testChannelId", + userChannelId = "testUserChannelId", + counterpartyNodeId = null, + reason = null, + ) + ) + + assertEquals("", sut.walletState.value.bolt11) + } + + @Test + fun `refreshBip21ForEvent ChannelReady should create invoice from live channels`() = test { + sut.setBip21AmountSats(SATS) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(lightningRepo.getChannels()).thenReturn(channels) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) + + sut.refreshBip21ForEvent(channelReady) + + verify(lightningRepo).createInvoice(anyOrNull(), any(), any()) + assertEquals(INVOICE, sut.walletState.value.bolt11) + } + @Test fun `refreshBip21ForEvent ChannelClosed should clear bolt11 when cannot receive`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) @@ -575,6 +610,7 @@ class WalletRepoTest : BaseUnitTest() { sut.setBolt11(INVOICE) whenever(lightningRepo.canReceive()).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) + whenever(lightningRepo.getChannels()).thenReturn(channels) sut.refreshBip21ForEvent( Event.ChannelClosed( @@ -741,6 +777,7 @@ class WalletRepoTest : BaseUnitTest() { fun `refreshBip21 should create a fresh invoice after PaymentReceived invalidates the old one`() = test { whenever(lightningRepo.canReceive()).thenReturn(true) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(channels = channels))) + whenever(lightningRepo.getChannels()).thenReturn(channels) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) From 3c214005599667007c5d20993b38c8961c3b2ad7 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 17:12:10 +0200 Subject: [PATCH 09/11] fix: use usable receive liquidity --- .../bitkit/models/ReceiveLiquidityDecision.kt | 4 +-- .../java/to/bitkit/repositories/WalletRepo.kt | 12 +++++--- .../wallets/receive/ReceiveQrScreen.kt | 12 ++++---- .../models/ReceiveLiquidityDecisionTest.kt | 12 ++++---- .../to/bitkit/repositories/WalletRepoTest.kt | 30 +++++++++++++++++++ docs/receive-liquidity.md | 12 ++++---- 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt index 43edaf2b09..fcbb555bea 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -24,11 +24,11 @@ data class ReceiveAdditionalLiquidityParams( object ReceiveLiquidityDecision { fun canCreateLightningInvoice( - hasReadyChannels: Boolean, + hasUsableChannels: Boolean, inboundCapacitySats: ULong?, invoiceAmountSats: ULong?, ): Boolean { - if (!hasReadyChannels || inboundCapacitySats == null) return false + if (!hasUsableChannels || inboundCapacitySats == null) return false if (invoiceAmountSats == null || invoiceAmountSats == 0uL) { return inboundCapacitySats > 0uL diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index c2183902a3..6a0fc8850f 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -756,14 +756,14 @@ class WalletRepo @Inject constructor( } fun inboundLiquiditySats(): ULong { - return currentChannels().calculateRemoteBalance() + return currentUsableChannels().calculateRemoteBalance() } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { - val channels = currentChannels() + val usableChannels = currentUsableChannels() return ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = channels.any { it.isUsable }, - inboundCapacitySats = channels.calculateRemoteBalance(), + hasUsableChannels = usableChannels.isNotEmpty(), + inboundCapacitySats = usableChannels.calculateRemoteBalance(), invoiceAmountSats = amountSats, ) } @@ -772,6 +772,10 @@ class WalletRepo @Inject constructor( return lightningRepo.getChannels() ?: lightningRepo.lightningState.value.channels } + private fun currentUsableChannels(): List { + return currentChannels().filter { it.isUsable } + } + private suspend fun Scanner.OnChain.extractLightningHash(): String? { val lightningInvoice: String = this.invoice.params?.get("lightning") ?: return null diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt index c11221459b..181870c83b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveQrScreen.kt @@ -107,16 +107,18 @@ fun ReceiveQrScreen( SetMaxBrightness() val haptic = LocalHapticFeedback.current - val inboundLiquiditySats = lightningState.channels.calculateRemoteBalance() - val hasUsableChannels = lightningState.channels.any { it.isChannelReady } + val hasUsableChannels = lightningState.channels.any { it.isUsable } + val usableInboundLiquiditySats = remember(lightningState.channels) { + lightningState.channels.filter { it.isUsable }.calculateRemoteBalance() + } val canCreateLightningInvoice = remember( hasUsableChannels, - lightningState.channels, + usableInboundLiquiditySats, walletState.bip21AmountSats, ) { ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = hasUsableChannels, - inboundCapacitySats = inboundLiquiditySats, + hasUsableChannels = hasUsableChannels, + inboundCapacitySats = usableInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, ) } diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 1275ce35b4..331cf23710 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -17,10 +17,10 @@ class ReceiveLiquidityDecisionTest { ) @Test - fun `lightning invoice requires ready channel`() { + fun `lightning invoice requires usable channel`() { assertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = false, + hasUsableChannels = false, inboundCapacitySats = 1_000u, invoiceAmountSats = null, ) @@ -31,7 +31,7 @@ class ReceiveLiquidityDecisionTest { fun `variable lightning invoice requires non-zero inbound liquidity`() { assertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = true, + hasUsableChannels = true, inboundCapacitySats = 0u, invoiceAmountSats = null, ) @@ -39,7 +39,7 @@ class ReceiveLiquidityDecisionTest { assertTrue( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = true, + hasUsableChannels = true, inboundCapacitySats = 1u, invoiceAmountSats = null, ) @@ -50,7 +50,7 @@ class ReceiveLiquidityDecisionTest { fun `fixed lightning invoice requires inbound liquidity covering amount`() { assertTrue( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = true, + hasUsableChannels = true, inboundCapacitySats = 5_000u, invoiceAmountSats = 5_000u, ) @@ -58,7 +58,7 @@ class ReceiveLiquidityDecisionTest { assertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasReadyChannels = true, + hasUsableChannels = true, inboundCapacitySats = 4_999u, invoiceAmountSats = 5_000u, ) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index aedf35c201..b102fa32aa 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -78,6 +78,13 @@ class WalletRepoTest : BaseUnitTest() { on { isUsable } doReturn true } ).toImmutableList() + val readyButNotUsableChannels = listOf( + mock { + on { inboundCapacityMsat } doReturn 1_000_000u + on { isChannelReady } doReturn true + on { isUsable } doReturn false + } + ).toImmutableList() private val channelReady = Event.ChannelReady( channelId = "testChannelId", userChannelId = "testUserChannelId", @@ -321,6 +328,29 @@ class WalletRepoTest : BaseUnitTest() { } } + @Test + fun `updateBip21Invoice should not create bolt11 when channels are ready but not usable`() = test { + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(channels = readyButNotUsableChannels))) + whenever(lightningRepo.getChannels()).thenReturn(readyButNotUsableChannels) + + sut.updateBip21Invoice(amountSats = SATS, description = "test").let { result -> + assertTrue(result.isSuccess) + assertEquals("", sut.walletState.value.bolt11) + } + verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + } + + @Test + fun `inboundLiquiditySats should only count usable channels`() = test { + val mixedChannels = (channels + readyButNotUsableChannels).toImmutableList() + whenever(lightningRepo.lightningState) + .thenReturn(MutableStateFlow(LightningState(channels = mixedChannels))) + whenever(lightningRepo.getChannels()).thenReturn(mixedChannels) + + assertEquals(1_000uL, sut.inboundLiquiditySats()) + } + @Test fun `updateBip21Invoice should build correct BIP21 URL`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(onchainAddress = ADDRESS))) diff --git a/docs/receive-liquidity.md b/docs/receive-liquidity.md index 86d3b92710..fd12b2986b 100644 --- a/docs/receive-liquidity.md +++ b/docs/receive-liquidity.md @@ -21,7 +21,7 @@ This document describes how the receive flow decides whether to show a normal Li - Returning from the edit flow preserves the hardware receive tab when the edit originated there. - Editing from Savings or Auto while a hardware wallet is available still returns to the source tab, not the hardware tab. -- Lightning receive unavailable because there is no ready channel or inbound liquidity is `0`: +- Lightning receive unavailable because there is no usable channel or usable inbound liquidity is `0`: - No Lightning invoice is created. - The normal QR remains Savings/onchain only. - The Spending tab shows CJIT onboarding. @@ -29,22 +29,22 @@ This document describes how the receive flow decides whether to show a normal Li - Editing from Savings or Auto updates the receive amount and returns to the normal QR; it does not create or route to CJIT. - When a channel already exists, later CJIT confirmation and learn-more screens use additional-liquidity copy. -- Ready channel, inbound liquidity greater than `0`, zero/variable amount: +- Usable channel, inbound liquidity greater than `0`, zero/variable amount: - A Lightning invoice is allowed. - A zero/variable Lightning invoice is allowed when inbound liquidity is greater than `0`, even though the sender could later choose an amount above the available inbound capacity. -- Ready channel, fixed amount less than or equal to inbound liquidity: +- Usable channel, fixed amount less than or equal to inbound liquidity: - A normal BOLT11 invoice is created. - The unified QR includes Lightning. - The Spending tab shows the normal Lightning invoice. -- Ready channel, fixed amount greater than inbound liquidity but below CJIT minimum: +- Usable channel, fixed amount greater than inbound liquidity but below CJIT minimum: - A normal Lightning invoice is not shown. - Editing from Spending routes to CJIT amount entry. - The user must choose at least the minimum CJIT amount. - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. -- Ready channel, fixed amount greater than inbound liquidity and at or above CJIT minimum: +- Usable channel, fixed amount greater than inbound liquidity and at or above CJIT minimum: - If editing from Spending and the amount can be backed by a CJIT channel without exceeding Blocktank's maximum channel size, the edit flow creates additional CJIT. - The user gets CJIT confirmation and then a CJIT Lightning invoice QR. - The CJIT Lightning invoice is an invoice to the LSP and must be shown as Spending-only, not as Auto/unified receive. @@ -60,5 +60,5 @@ This document describes how the receive flow decides whether to show a normal Li ## Invariants - Auto tab availability and default tab selection are based on whether a normal Lightning invoice can be created for the current receive amount. -- Ready channels alone do not imply Auto availability; fixed receive amounts must also fit within inbound liquidity. +- Ready channels alone do not imply Auto availability; the channel must be usable, and fixed receive amounts must fit within usable inbound liquidity. - CJIT min and max limits are only needed when a Spending-origin edit needs additional inbound liquidity and the user is not geo-blocked. From 10355fa02a7ab5635401dcbc5d5e476efbfb0738 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 18:15:33 +0200 Subject: [PATCH 10/11] fix: use usable receive liquidity --- .../bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index d1eb27e4f3..f82c23e85f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt @@ -106,6 +106,7 @@ fun EditInvoiceScreen( val blocktankVM = blocktankViewModel ?: return var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() + var isCreatingCjit by remember { mutableStateOf(false) } val amountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentReceiveSats by rememberUpdatedState(amountInputUiState.sats.toULong()) val isLoading by editInvoiceVM.isLoading.collectAsStateWithLifecycle() @@ -126,6 +127,7 @@ fun EditInvoiceScreen( navigateCjitAmount() } is ReceiveAdditionalLiquidityAction.CreateCjit -> { + isCreatingCjit = true runSuspendCatching { blocktankVM.createCjit(action.amountSats) }.onSuccess { entry -> navigateReceiveConfirm( CjitEntryDetails( @@ -144,6 +146,7 @@ fun EditInvoiceScreen( } navigateCjitAmount() } + isCreatingCjit = false } ReceiveAdditionalLiquidityAction.GeoBlocked -> navigateGeoBlock() } @@ -179,7 +182,7 @@ fun EditInvoiceScreen( updateOnchainInvoice(amountSats) onBack() }, - isLoading = isLoading, + isLoading = isLoading || isCreatingCjit, onClickAddTag = onClickAddTag, onClickTag = onClickTag, isSoftKeyboardVisible = isSoftKeyboardVisible, From 07ec441046b852739c95779dfc295de54e873ad4 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 7 Sep 2026 20:16:50 +0200 Subject: [PATCH 11/11] fix: move receive liquidity read off main --- app/src/main/java/to/bitkit/repositories/WalletRepo.kt | 4 ++-- .../to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt | 8 +++++--- .../test/java/to/bitkit/repositories/WalletRepoTest.kt | 5 +++++ .../ui/screens/wallets/receive/EditInvoiceVMTest.kt | 6 +++++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 6a0fc8850f..587f91aa07 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -755,8 +755,8 @@ class WalletRepo @Inject constructor( } } - fun inboundLiquiditySats(): ULong { - return currentUsableChannels().calculateRemoteBalance() + suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) { + return@withContext currentUsableChannels().calculateRemoteBalance() } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt index 00befe6484..654a0dcf43 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVM.kt @@ -42,12 +42,13 @@ class EditInvoiceVM @Inject constructor( ) { viewModelScope.launch { _isLoading.update { true } - val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, isGeoBlocked) + val inboundCapacitySats = walletRepo.inboundLiquiditySats() + val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, inboundCapacitySats, isGeoBlocked) val action = ReceiveLiquidityDecision.additionalLiquidityAction( ReceiveAdditionalLiquidityParams( source = source, invoiceAmountSats = amountSats, - inboundCapacitySats = walletRepo.inboundLiquiditySats(), + inboundCapacitySats = inboundCapacitySats, minCjitSats = blocktankRepo.blocktankState.value.minCjitSats?.toULong(), maxCjitAmountSats = maxCjitAmountSats, isGeoBlocked = isGeoBlocked, @@ -61,12 +62,13 @@ class EditInvoiceVM @Inject constructor( private suspend fun maxCjitAmountSats( source: ReceiveLiquiditySource, amountSats: ULong, + inboundCapacitySats: ULong, isGeoBlocked: Boolean, ): ULong? { if (!ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( source = source, invoiceAmountSats = amountSats, - inboundCapacitySats = walletRepo.inboundLiquiditySats(), + inboundCapacitySats = inboundCapacitySats, isGeoBlocked = isGeoBlocked, ) ) { diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index b102fa32aa..4cbf4f91b3 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -305,6 +305,7 @@ class WalletRepoTest : BaseUnitTest() { ) verify(onchainService, never()).deriveBitcoinAddress(any(), any(), any(), anyOrNull()) + verify(lightningRepo, never()).syncState() } @Test @@ -572,6 +573,7 @@ class WalletRepoTest : BaseUnitTest() { assertEquals(INVOICE, sut.walletState.value.bolt11) assertEquals(SATS, sut.walletState.value.bip21AmountSats) assertEquals(testDescription, sut.walletState.value.bip21Description) + verify(lightningRepo).syncState() } @Test @@ -582,6 +584,7 @@ class WalletRepoTest : BaseUnitTest() { sut.refreshBip21ForEvent(channelReady) verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + verify(lightningRepo).syncState() } @Test @@ -600,6 +603,7 @@ class WalletRepoTest : BaseUnitTest() { ) assertEquals("", sut.walletState.value.bolt11) + verify(lightningRepo).syncState() } @Test @@ -612,6 +616,7 @@ class WalletRepoTest : BaseUnitTest() { sut.refreshBip21ForEvent(channelReady) verify(lightningRepo).createInvoice(anyOrNull(), any(), any()) + verify(lightningRepo).syncState() assertEquals(INVOICE, sut.walletState.value.bolt11) } diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt index a461929f57..9bacae9389 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceVMTest.kt @@ -2,9 +2,12 @@ package to.bitkit.ui.screens.wallets.receive import app.cash.turbine.test import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.models.ReceiveAdditionalLiquidityAction import to.bitkit.models.ReceiveLiquiditySource @@ -22,7 +25,7 @@ class EditInvoiceVMTest : BaseUnitTest() { private val blocktankRepo: BlocktankRepo = mock() @Before - fun setUp() { + fun setUp() = runBlocking { whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState(minCjitSats = 5_000))) whenever(walletRepo.inboundLiquiditySats()).thenReturn(1_000u) sut = EditInvoiceVM(walletRepo, blocktankRepo) @@ -43,6 +46,7 @@ class EditInvoiceVMTest : BaseUnitTest() { ) cancelAndIgnoreRemainingEvents() } + verify(walletRepo, times(1)).inboundLiquiditySats() } @Test