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..fcbb555bea --- /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( + hasUsableChannels: Boolean, + inboundCapacitySats: ULong?, + invoiceAmountSats: ULong?, + ): Boolean { + if (!hasUsableChannels || 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..5a30fdb06d 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -54,9 +54,11 @@ 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.msatCeilOf +import to.bitkit.models.safe import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.utils.Logger @@ -249,11 +251,18 @@ 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() + val maxChannelSizeSat = freshMaxChannelSizeSat() + if (maxChannelSizeSat != null && amountSats > maxChannelSizeSat) { + throw ServiceError.ChannelSizeExceedsMaximum() + } val lspBalance = getDefaultLspBalance(clientBalance = amountSats) - val channelSizeSat = amountSats + lspBalance + if (!canFitChannelSize(amountSats, lspBalance)) { + throw ServiceError.ChannelSizeExceedsMaximum() + } + val channelSizeSat = amountSats.safe() + lspBalance.safe() val cjitEntry = coreService.blocktank.createCjit( channelSizeSat = channelSizeSat, @@ -266,12 +275,38 @@ 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 maxCjitAmountSats(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runSuspendCatching null + var lowerBound = 0uL + var upperBound = maxChannelSizeSat + + while (lowerBound < upperBound) { + 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 { + 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,30 @@ class BlocktankRepo @Inject constructor( return@withContext getDefaultLspBalance(params) } + private suspend fun freshMaxChannelSizeSat(): ULong? { + refreshInfo() + + return _blocktankState.value.info?.options?.maxChannelSizeSat?.takeIf { it > 0uL } + } + + private suspend fun canCreateCjit(amountSats: ULong, maxChannelSizeSat: ULong): Boolean { + if (amountSats > maxChannelSizeSat) return false + + val lspBalance = getDefaultLspBalance(clientBalance = 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 + + if (amountSats > maxChannelSizeSat) return false + + val remainingCapacity = maxChannelSizeSat.safe() - amountSats.safe() + return lspBalance <= remainingCapacity + } + fun calculateLiquidityOptions(clientBalanceSat: ULong): Result { val blocktankInfo = blocktankState.value.info ?: return Result.failure(ServiceError.BlocktankInfoUnavailable()) @@ -600,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/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 96edad2fb3..587f91aa07 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 @@ -28,7 +29,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 +37,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 @@ -318,11 +319,18 @@ 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 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 +344,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 +735,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 +755,25 @@ class WalletRepo @Inject constructor( } } - suspend fun shouldRequestAdditionalLiquidity(): Result = withContext(bgDispatcher) { - runCatching { - if (coreService.isGeoBlocked()) return@runCatching false + suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) { + return@withContext currentUsableChannels().calculateRemoteBalance() + } - val channels = lightningRepo.lightningState.value.channels - if (channels.filterOpen().isEmpty()) return@runCatching false + private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { + val usableChannels = currentUsableChannels() + return ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = usableChannels.isNotEmpty(), + inboundCapacitySats = usableChannels.calculateRemoteBalance(), + invoiceAmountSats = amountSats, + ) + } - val inboundBalanceSats = channels.sumOf { msatFloorOf(it.inboundCapacityMsat) } + private fun currentChannels(): List { + return lightningRepo.getChannels() ?: lightningRepo.lightningState.value.channels + } - return@runCatching (_walletState.value.bip21AmountSats ?: 0uL) >= inboundBalanceSats - }.onFailure { - Logger.error("shouldRequestAdditionalLiquidity error", it, context = TAG) - } + private fun currentUsableChannels(): List { + return currentChannels().filter { it.isUsable } } 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..3ebba1bb58 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(receiveSheetPresentationKey(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( @@ -1966,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/EditInvoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/EditInvoiceScreen.kt index 5981ce6bdf..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 @@ -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,17 @@ 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 +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 +76,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 +85,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 +97,60 @@ 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() + 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() 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 -> { + isCreatingCjit = true + runSuspendCatching { 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() + } + isCreatingCjit = false + } + ReceiveAdditionalLiquidityAction.GeoBlocked -> navigateGeoBlock() } } - - EditInvoiceVM.EditInvoiceScreenEffects.UpdateInvoice -> { - updateInvoice(receiveSats) - onBack() - } } } } @@ -148,12 +171,18 @@ 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() }, - isLoading = isLoading, + isLoading = isLoading || isCreatingCjit, onClickAddTag = onClickAddTag, onClickTag = onClickTag, isSoftKeyboardVisible = isSoftKeyboardVisible, @@ -165,6 +194,15 @@ fun EditInvoiceScreen( ) } +private fun ReceiveTab.toReceiveLiquiditySource(): ReceiveLiquiditySource { + return when (this) { + ReceiveTab.SAVINGS -> SAVINGS + ReceiveTab.AUTO -> AUTO + ReceiveTab.SPENDING -> SPENDING + ReceiveTab.TREZOR -> SAVINGS + } +} + @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..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 @@ -9,13 +9,18 @@ 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,29 +35,53 @@ 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 inboundCapacitySats = walletRepo.inboundLiquiditySats() + val maxCjitAmountSats = maxCjitAmountSats(source, amountSats, inboundCapacitySats, isGeoBlocked) + val action = ReceiveLiquidityDecision.additionalLiquidityAction( + ReceiveAdditionalLiquidityParams( + source = source, + invoiceAmountSats = amountSats, + inboundCapacitySats = inboundCapacitySats, + minCjitSats = blocktankRepo.blocktankState.value.minCjitSats?.toULong(), + maxCjitAmountSats = maxCjitAmountSats, + isGeoBlocked = isGeoBlocked, + ) + ) + editInvoiceEffect(EditInvoiceScreenEffects.ApplyReceiveLiquidityAction(action)) _isLoading.update { false } } } - sealed interface EditInvoiceScreenEffects { - data object UpdateInvoice : EditInvoiceScreenEffects - data object NavigateAddLiquidity : EditInvoiceScreenEffects + private suspend fun maxCjitAmountSats( + source: ReceiveLiquiditySource, + amountSats: ULong, + inboundCapacitySats: ULong, + isGeoBlocked: Boolean, + ): ULong? { + if (!ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source = source, + invoiceAmountSats = amountSats, + inboundCapacitySats = inboundCapacitySats, + isGeoBlocked = isGeoBlocked, + ) + ) { + return null + } + + blocktankRepo.refreshMinCjitSats() + return blocktankRepo.maxCjitAmountSats().getOrNull() } - companion object { - const val TAG = "EditInvoiceVM" + sealed interface EditInvoiceScreenEffects { + data class ApplyReceiveLiquidityAction( + val action: ReceiveAdditionalLiquidityAction, + ) : EditInvoiceScreenEffects } } 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..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 @@ -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 @@ -28,7 +29,10 @@ 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 import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel @@ -51,10 +55,12 @@ 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 -@Suppress("ViewModelForwarding") +@Suppress("CyclomaticComplexMethod", "ViewModelForwarding") @Composable fun ReceiveAmountScreen( onCjitCreated: (CjitEntryDetails) -> Unit, @@ -63,16 +69,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 = runSuspendCatching { 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,14 +123,21 @@ 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 { + runSuspendCatching { require(lightningState.nodeLifecycleState == NodeLifecycleState.Running) { "Should not be able to land on this screen if the node is not running." } @@ -106,8 +154,13 @@ fun ReceiveAmountScreen( ) ) }.onFailure { e -> - app.toast(e) Logger.error("Failed to create CJIT", e) + if (e is ServiceError.ChannelSizeExceedsMaximum) { + maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() + maxCjitAmountSats?.let { showMaxExceededToast(it) } ?: app.toast(e) + } else { + app.toast(e) + } } isCreatingInvoice = false } @@ -172,7 +225,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/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..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 @@ -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,9 +93,9 @@ fun ReceiveQrScreen( cjitInvoice: String?, walletState: WalletState, lightningState: LightningState, - onClickEditInvoice: () -> Unit, + onClickEditInvoice: (ReceiveTab) -> Unit, onClickReceiveCjit: () -> Unit, - onClickHardwareEditInvoice: () -> Unit = onClickEditInvoice, + onClickHardwareEditInvoice: () -> Unit = { onClickEditInvoice(ReceiveTab.TREZOR) }, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, hardwareWalletId: String? = null, @@ -105,22 +107,39 @@ fun ReceiveQrScreen( SetMaxBrightness() val haptic = LocalHapticFeedback.current - 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, + usableInboundLiquiditySats, + walletState.bip21AmountSats, + ) { + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = hasUsableChannels, + inboundCapacitySats = usableInboundLiquiditySats, + 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 +159,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, @@ -151,7 +171,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( @@ -161,20 +181,35 @@ 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) } + 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) { - selectedTab = visibleTabs.first() + val fallbackTab = visibleTabs.defaultReceiveTab() + selectedTab = fallbackTab + lazyListState.scrollToItem(visibleTabs.indexOf(fallbackTab).coerceAtLeast(0)) + } + } + + 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)) } } @@ -190,8 +225,9 @@ 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) { + 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) @@ -218,8 +254,8 @@ fun ReceiveQrScreen( } } - val showingCjitOnboarding = remember(lightningState, cjitInvoice, hasUsableChannels) { - !hasUsableChannels && + val showingCjitOnboarding = remember(lightningState, cjitInvoice, canCreateLightningInvoice) { + !canCreateLightningInvoice && lightningState.nodeLifecycleState.isRunning() && cjitInvoice.isNullOrEmpty() } @@ -252,7 +288,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 +333,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 +362,7 @@ fun ReceiveQrScreen( onClickEditInvoice = if (tab == ReceiveTab.TREZOR) { onClickHardwareEditInvoice } else if (cjitInvoice.isNullOrEmpty()) { - onClickEditInvoice + { onClickEditInvoice(tab) } } else { onClickReceiveCjit }, @@ -401,7 +437,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 +454,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 +489,7 @@ private fun ReceiveQrView( VerticalSpacer(16.dp) Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, ) { PrimaryButton( @@ -579,7 +619,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 +762,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..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 @@ -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 = { - invoiceEditState.beginSoftwareEdit() + editInvoiceSourceTab = it + invoiceEditState.beginSoftwareEdit(it) navController.navigateTo(ReceiveRoute.EditInvoice) }, onClickHardwareEditInvoice = { + editInvoiceSourceTab = ReceiveTab.TREZOR 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 { @@ -344,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/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..0c39fe14c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1251,6 +1251,8 @@ 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. Invoice copied to clipboard @@ -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..331cf23710 --- /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 usable channel`() { + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = false, + inboundCapacitySats = 1_000u, + invoiceAmountSats = null, + ) + ) + } + + @Test + fun `variable lightning invoice requires non-zero inbound liquidity`() { + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = true, + inboundCapacitySats = 0u, + invoiceAmountSats = null, + ) + ) + + assertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = true, + inboundCapacitySats = 1u, + invoiceAmountSats = null, + ) + ) + } + + @Test + fun `fixed lightning invoice requires inbound liquidity covering amount`() { + assertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = true, + inboundCapacitySats = 5_000u, + invoiceAmountSats = 5_000u, + ) + ) + + assertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasUsableChannels = 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/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 6a7cf2bd69..909a8b7d4b 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,6 +16,7 @@ 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 @@ -24,7 +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.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -190,6 +194,58 @@ class BlocktankRepoTest : BaseUnitTest() { } } + @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.createCjit(amountSats = 100_000u) + + 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() @@ -400,6 +456,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()), diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 393d623f62..4cbf4f91b3 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -70,10 +70,19 @@ 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() + val readyButNotUsableChannels = listOf( + mock { + on { inboundCapacityMsat } doReturn 1_000_000u + on { isChannelReady } doReturn true + on { isUsable } doReturn false } ).toImmutableList() private val channelReady = Event.ChannelReady( @@ -296,11 +305,14 @@ class WalletRepoTest : BaseUnitTest() { ) verify(onchainService, never()).deriveBitcoinAddress(any(), any(), any(), anyOrNull()) + verify(lightningRepo, never()).syncState() } @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.getChannels()).thenReturn(channels) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) sut.updateBip21Invoice(amountSats = SATS, description = "test").let { result -> @@ -317,6 +329,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))) @@ -492,52 +527,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) @@ -575,6 +564,8 @@ class WalletRepoTest : BaseUnitTest() { sut.setBip21AmountSats(SATS) 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) @@ -582,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 @@ -592,6 +584,40 @@ class WalletRepoTest : BaseUnitTest() { sut.refreshBip21ForEvent(channelReady) verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + verify(lightningRepo).syncState() + } + + @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) + verify(lightningRepo).syncState() + } + + @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()) + verify(lightningRepo).syncState() + assertEquals(INVOICE, sut.walletState.value.bolt11) } @Test @@ -618,6 +644,8 @@ 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))) + whenever(lightningRepo.getChannels()).thenReturn(channels) sut.refreshBip21ForEvent( Event.ChannelClosed( @@ -783,6 +811,8 @@ 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.getChannels()).thenReturn(channels) whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())) .thenReturn(Result.success(INVOICE_REPLACEMENT)) sut.setOnchainAddress(ADDRESS) 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)) + } } 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..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 @@ -1,11 +1,18 @@ 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 +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 +22,87 @@ class EditInvoiceVMTest : BaseUnitTest() { private lateinit var sut: EditInvoiceVM private val walletRepo: WalletRepo = mock() + private val blocktankRepo: BlocktankRepo = mock() @Before - fun setUp() { - sut = EditInvoiceVM(walletRepo) + fun setUp() = runBlocking { + 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() + verify(walletRepo, times(1)).inboundLiquiditySats() } @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/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/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/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 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..fd12b2986b --- /dev/null +++ b/docs/receive-liquidity.md @@ -0,0 +1,64 @@ +# 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. + +- 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 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. + - 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. + +- 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. + +- 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. + +- 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. + +- 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. + - 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; 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.