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..cb39cc596a 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -252,7 +252,11 @@ 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() + } val channelSizeSat = amountSats + lspBalance val cjitEntry = coreService.blocktank.createCjit( @@ -272,6 +276,36 @@ class BlocktankRepo @Inject constructor( } } + suspend fun canCreateCjit(amountSats: ULong): Result = withContext(bgDispatcher) { + runCatching { + val maxChannelSizeSat = freshMaxChannelSizeSat() ?: return@runCatching true + return@runCatching 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 + var lowerBound = 0uL + var upperBound = maxChannelSizeSat + + while (lowerBound < upperBound) { + val candidate = lowerBound + (upperBound - lowerBound + 1uL) / 2uL + 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 +443,26 @@ class BlocktankRepo @Inject constructor( return@withContext getDefaultLspBalance(params) } + 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 { + if (amountSats > maxChannelSizeSat) return false + + 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 + + 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 b34954e346..895519abb9 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,16 +510,19 @@ fun ContentView( is Sheet.Receive -> { val walletState by walletViewModel.walletState.collectAsStateWithLifecycle() val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() - ReceiveSheet( - appViewModel = appViewModel, - startRoute = sheet.route, - walletState = walletState, - isOffline = connectivityState != ConnectivityState.CONNECTED, - navigateToExternalConnection = { - navController.navigateTo(ExternalConnection()) - appViewModel.hideSheet() - }, - ) + + key(System.identityHashCode(sheet)) { + ReceiveSheet( + appViewModel = appViewModel, + startRoute = sheet.route, + 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 1df8296e3d..259243ea63 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 @@ -75,6 +83,8 @@ import to.bitkit.viewmodels.previewAmountInputViewModel fun EditInvoiceScreen( amountInputViewModel: AmountInputViewModel, walletUiState: WalletState, + lightningState: LightningState, + sourceTab: ReceiveTab, updateInvoice: (ULong?) -> Unit, onClickAddTag: () -> Unit, onClickTag: (String) -> Unit, @@ -83,48 +93,56 @@ fun EditInvoiceScreen( onClickPaymentRequest: (amountSats: ULong, note: String) -> Unit, onBack: () -> Unit, navigateReceiveConfirm: (CjitEntryDetails) -> 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(Unit) { 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.isCjitMaxAmountError()) { + app.toast(it) + } + navigateCjitAmount() + } + } + ReceiveAdditionalLiquidityAction.GeoBlocked -> navigateGeoBlock() } } - - EditInvoiceVM.EditInvoiceScreenEffects.UpdateInvoice -> { - updateInvoice(receiveSats) - onBack() - } } } } @@ -145,7 +163,13 @@ fun EditInvoiceScreen( } }, onContinueKeyboard = { keyboardVisible = false }, - onContinueGeneral = { editInvoiceVM.onClickContinue() }, + onContinueGeneral = { + editInvoiceVM.onClickContinue( + source = sourceTab.toReceiveLiquiditySource(), + amountSats = amountInputUiState.sats.toULong(), + isGeoBlocked = lightningState.isGeoBlocked, + ) + }, isLoading = isLoading, onClickAddTag = onClickAddTag, onClickTag = onClickTag, @@ -157,6 +181,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 5c520fef6f..26fc973020 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 @@ -19,6 +19,7 @@ fun getInvoiceForTab( bolt11: String, cjitInvoice: String?, isNodeRunning: Boolean, + canCreateLightningInvoice: Boolean = true, onchainAddress: String, ): String { return when (tab) { @@ -28,13 +29,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() } } } 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 289a6f2587..86fff00e28 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 @@ -54,8 +54,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 @@ -67,7 +69,6 @@ import to.bitkit.ui.components.Display import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.QrCodeImage -import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.Tooltip import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar @@ -89,7 +90,7 @@ fun ReceiveQrScreen( cjitInvoice: String?, walletState: WalletState, lightningState: LightningState, - onClickEditInvoice: () -> Unit, + onClickEditInvoice: (ReceiveTab) -> Unit, onClickReceiveCjit: () -> Unit, modifier: Modifier = Modifier, initialTab: ReceiveTab? = null, @@ -97,19 +98,34 @@ 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) { + val visibleTabs = remember(canCreateLightningInvoice, cjitInvoice) { buildList { 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, @@ -126,6 +142,7 @@ fun ReceiveQrScreen( bolt11 = walletState.bolt11, cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), + canCreateLightningInvoice = canCreateLightningInvoice, onchainAddress = walletState.onchainAddress, ) } @@ -142,12 +159,20 @@ 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) } LaunchedEffect(visibleTabs) { if (selectedTab !in visibleTabs) { selectedTab = visibleTabs.first() + lazyListState.scrollToItem(0) + } + } + + LaunchedEffect(canCreateLightningInvoice, cjitInvoice) { + if (!canCreateLightningInvoice && cjitInvoice.isNullOrEmpty()) { + selectedTab = ReceiveTab.SAVINGS + lazyListState.scrollToItem(0) } } @@ -163,8 +188,8 @@ fun ReceiveQrScreen( } // Auto-switch to AUTO tab when it becomes available for the first time - LaunchedEffect(hasUsableChannels) { - if (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) @@ -184,8 +209,8 @@ fun ReceiveQrScreen( } } - val showingCjitOnboarding = remember(lightningState, cjitInvoice, hasUsableChannels) { - !hasUsableChannels && + val showingCjitOnboarding = remember(lightningState, cjitInvoice, canCreateLightningInvoice) { + !canCreateLightningInvoice && lightningState.nodeLifecycleState.isRunning() && cjitInvoice.isNullOrEmpty() } @@ -217,7 +242,7 @@ fun ReceiveQrScreen( modifier = Modifier.padding(horizontal = 16.dp) ) - VerticalSpacer(24.dp) + VerticalSpacer(16.dp) // Content area (QR or Details) with LazyRow LazyRow( @@ -253,7 +278,7 @@ fun ReceiveQrScreen( walletState = walletState, cjitInvoice = cjitInvoice, isNodeRunning = lightningState.nodeLifecycleState.isRunning(), - onClickEditInvoice = onClickEditInvoice, + onClickEditInvoice = { onClickEditInvoice(tab) }, modifier = Modifier.weight(1f) ) } @@ -274,7 +299,7 @@ fun ReceiveQrScreen( copyText = copyText, qrLogoPainter = painterResource(getQrLogoResource(tab)), onClickEditInvoice = if (cjitInvoice.isNullOrEmpty()) { - onClickEditInvoice + { onClickEditInvoice(tab) } } else { onClickReceiveCjit }, @@ -336,7 +361,7 @@ fun ReceiveQrScreen( .testTag("QRCode") ) - BottomButtonVariant.SHOW_DETAILS -> TertiaryButton( + BottomButtonVariant.SHOW_DETAILS -> PrimaryButton( text = stringResource(R.string.wallet__receive_show_details), onClick = { showDetails = true }, fullWidth = true, @@ -352,6 +377,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( @@ -383,7 +412,7 @@ private fun ReceiveQrView( VerticalSpacer(16.dp) Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top, ) { PrimaryButton( @@ -510,7 +539,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()) { @@ -602,19 +634,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), @@ -869,7 +900,7 @@ private fun PreviewSmall() { lightningState = LightningState( nodeLifecycleState = NodeLifecycleState.Running, ), - onClickEditInvoice = {}, + onClickEditInvoice = { _ -> }, modifier = Modifier.sheetHeight(), onClickReceiveCjit = {}, ) 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 020aba5a78..64330ce44e 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 @@ -68,8 +68,9 @@ fun ReceiveSheet( LaunchedEffect(startRoute) { navController.navigateToReceiveStart(startRoute) } val cjitInvoice = remember { mutableStateOf(null) } - val showCreateCjit = remember { mutableStateOf(false) } val cjitEntryDetails = remember { mutableStateOf(null) } + 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 { @@ -104,10 +105,6 @@ fun ReceiveSheet( startDestination = rootRoute, ) { composableWithDefaultTransitions { - LaunchedEffect(cjitInvoice.value) { - showCreateCjit.value = !cjitInvoice.value.isNullOrBlank() - } - ReceiveQrScreen( cjitInvoice = cjitInvoice.value, walletState = walletState, @@ -116,11 +113,14 @@ fun ReceiveSheet( if (lightningState.isGeoBlocked) { navController.navigateTo(ReceiveRoute.GeoBlock) } else { - showCreateCjit.value = true + isAdditionalLiquidityAmountEntry = lightningState.channels.isNotEmpty() navController.navigateTo(ReceiveRoute.Amount) } }, - onClickEditInvoice = { navController.navigateTo(ReceiveRoute.EditInvoice) }, + onClickEditInvoice = { + editInvoiceSourceTab = it + navController.navigateTo(ReceiveRoute.EditInvoice) + }, ) } composableWithDefaultTransitions { @@ -173,7 +173,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() }, ) @@ -260,6 +266,8 @@ fun ReceiveSheet( EditInvoiceScreen( amountInputViewModel = editInvoiceAmountViewModel, walletUiState = walletUiState, + lightningState = lightningState, + sourceTab = editInvoiceSourceTab, onBack = { navController.popBackStack() }, updateInvoice = wallet::updateBip21Invoice, onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, @@ -278,6 +286,11 @@ fun ReceiveSheet( cjitEntryDetails.value = entry navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) }, + 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 cce3f106cd..0c220425ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1249,6 +1249,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. @@ -1260,10 +1262,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/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 6a7cf2bd69..54235af93d 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 @@ -25,6 +27,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.LightningService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -190,6 +193,21 @@ class BlocktankRepoTest : BaseUnitTest() { } } + @Test + fun `canCreateCjit 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) + + sut.refreshInfo() + val result = sut.canCreateCjit(amountSats = 100_000u) + + assertFalse(result.getOrThrow()) + verify(coreService.blocktank, times(3)).info(refresh = true) + } + @Test fun `getOrder returns failure when refresh fails`() { sut = createSut() @@ -400,6 +418,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..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 -> @@ -492,52 +493,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 +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) @@ -618,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( @@ -783,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) 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 7905b54273..ac17808d40 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 @@ -104,7 +104,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( @@ -116,11 +116,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( @@ -132,11 +132,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( @@ -148,7 +148,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 @@ -184,7 +201,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( @@ -199,6 +216,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.