From a5332c038460f888c7e6111323ea956d44bde4bd Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 10 Sep 2026 17:22:47 +0200 Subject: [PATCH 1/6] fix: align receive liquidity with spec --- .../to/bitkit/models/CjitQuoteValidator.kt | 22 ++++++++ .../bitkit/models/ReceiveLiquidityDecision.kt | 4 +- .../to/bitkit/repositories/BlocktankRepo.kt | 26 +++++++-- .../java/to/bitkit/repositories/WalletRepo.kt | 12 ++-- .../wallets/receive/EditInvoiceScreen.kt | 17 ++---- .../wallets/receive/ReceiveAmountScreen.kt | 24 +++----- .../receive/ReceiveCjitErrorPresenter.kt | 33 +++++++++++ .../wallets/receive/ReceiveConfirmScreen.kt | 23 +++++++- .../wallets/receive/ReceiveQrScreen.kt | 18 +++--- .../screens/wallets/receive/ReceiveSheet.kt | 56 ++++++++++++++----- app/src/main/java/to/bitkit/utils/Errors.kt | 2 + app/src/main/res/values/strings.xml | 4 ++ .../bitkit/models/CjitQuoteValidatorTest.kt | 52 +++++++++++++++++ .../models/ReceiveLiquidityDecisionTest.kt | 12 ++-- .../bitkit/repositories/BlocktankRepoTest.kt | 4 +- .../to/bitkit/repositories/WalletRepoTest.kt | 10 ++-- .../wallets/receive/CjitEntryDetailsTest.kt | 49 ++++++++++++++++ .../receive/ReceiveInvoiceEditStateTest.kt | 48 ++++++++++++++++ .../next/receive-liquidity-cjit.fixed.md | 1 + docs/receive-liquidity.md | 30 ++++++---- 20 files changed, 360 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt create mode 100644 app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt create mode 100644 changelog.d/next/receive-liquidity-cjit.fixed.md diff --git a/app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt b/app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt new file mode 100644 index 0000000000..5b28cd337f --- /dev/null +++ b/app/src/main/java/to/bitkit/models/CjitQuoteValidator.kt @@ -0,0 +1,22 @@ +package to.bitkit.models + +import to.bitkit.utils.ServiceError + +object CjitQuoteValidator { + fun validate( + invoiceSat: ULong, + feeSat: ULong, + channelSizeSat: ULong, + ): Result { + if (feeSat >= invoiceSat) { + return Result.failure(ServiceError.CjitQuoteInvalid()) + } + + val netReceiveSat = invoiceSat - feeSat + if (channelSizeSat < netReceiveSat) { + return Result.failure(ServiceError.CjitQuoteInvalid()) + } + + return Result.success(Unit) + } +} diff --git a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt index fcbb555bea..43edaf2b09 100644 --- a/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt +++ b/app/src/main/java/to/bitkit/models/ReceiveLiquidityDecision.kt @@ -24,11 +24,11 @@ data class ReceiveAdditionalLiquidityParams( object ReceiveLiquidityDecision { fun canCreateLightningInvoice( - hasUsableChannels: Boolean, + hasReadyChannels: Boolean, inboundCapacitySats: ULong?, invoiceAmountSats: ULong?, ): Boolean { - if (!hasUsableChannels || inboundCapacitySats == null) return false + if (!hasReadyChannels || inboundCapacitySats == null) return false if (invoiceAmountSats == null || invoiceAmountSats == 0uL) { return inboundCapacitySats > 0uL diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index da1d01f8af..59565327b7 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -61,6 +61,7 @@ import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.nowTimestamp import to.bitkit.ext.runSuspendCatching import to.bitkit.models.BlocktankBackupV1 +import to.bitkit.models.CjitQuoteValidator import to.bitkit.models.EUR import to.bitkit.models.msatCeilOf import to.bitkit.models.safe @@ -279,6 +280,11 @@ class BlocktankRepo @Inject constructor( channelExpiryWeeks = DEFAULT_CHANNEL_EXPIRY_WEEKS, options = CreateCjitOptions(source = DEFAULT_SOURCE, discountCode = null) ) + CjitQuoteValidator.validate( + invoiceSat = amountSats, + feeSat = cjitEntry.feeSat, + channelSizeSat = cjitEntry.channelSizeSat, + ).getOrThrow() repoScope.launch { refreshOrders() } @@ -728,22 +734,30 @@ class BlocktankRepo @Inject constructor( } internal fun Throwable.toCjitError(): Throwable { - if (this is ServiceError.ChannelSizeExceedsMaximum) return this + if (this is ServiceError.ChannelSizeExceedsMaximum || + this is ServiceError.CjitQuoteInvalid || + this is ServiceError.NodeCapacityUnavailable + ) { + return this + } - return if (isMaxChannelSizeError()) { - ServiceError.ChannelSizeExceedsMaximum() - } else { - this + return when { + isNodeCapacityError() -> ServiceError.NodeCapacityUnavailable() + isMaxChannelSizeError() -> ServiceError.ChannelSizeExceedsMaximum() + else -> this } } +private fun Throwable.isNodeCapacityError(): Boolean { + return toString().contains("capacity is above our capacity limit", ignoreCase = true) +} + 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) } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 587f91aa07..a3e5b4d325 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -756,14 +756,14 @@ class WalletRepo @Inject constructor( } suspend fun inboundLiquiditySats(): ULong = withContext(bgDispatcher) { - return@withContext currentUsableChannels().calculateRemoteBalance() + return@withContext currentReadyChannels().calculateRemoteBalance() } private fun canCreateLightningInvoice(amountSats: ULong?): Boolean { - val usableChannels = currentUsableChannels() + val readyChannels = currentReadyChannels() return ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = usableChannels.isNotEmpty(), - inboundCapacitySats = usableChannels.calculateRemoteBalance(), + hasReadyChannels = readyChannels.isNotEmpty(), + inboundCapacitySats = readyChannels.calculateRemoteBalance(), invoiceAmountSats = amountSats, ) } @@ -772,8 +772,8 @@ class WalletRepo @Inject constructor( return lightningRepo.getChannels() ?: lightningRepo.lightningState.value.channels } - private fun currentUsableChannels(): List { - return currentChannels().filter { it.isUsable } + private fun currentReadyChannels(): List { + return currentChannels().filter { it.isChannelReady } } private suspend fun Scanner.OnChain.extractLightningHash(): String? { 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 f82c23e85f..47e30669b2 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -103,6 +104,7 @@ fun EditInvoiceScreen( editInvoiceVM: EditInvoiceVM = hiltViewModel(), ) { val app = appViewModel ?: return + val context = LocalContext.current val blocktankVM = blocktankViewModel ?: return var keyboardVisible by remember { mutableStateOf(false) } var isSoftKeyboardVisible by keyboardAsState() @@ -129,19 +131,12 @@ fun EditInvoiceScreen( 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, - ) - ) + navigateReceiveConfirm(CjitEntryDetails.from(entry, action.amountSats).getOrThrow()) }.onFailure { Logger.error("Failed to create CJIT invoice", it, context = "EditInvoiceScreen") - if (it !is ServiceError.ChannelSizeExceedsMaximum) { + if (!app.toastReceiveCjitError(context, it) && + it !is ServiceError.ChannelSizeExceedsMaximum + ) { app.toast(it) } navigateCjitAmount() 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 b075a9a3b2..d640671c03 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 @@ -143,23 +143,17 @@ fun ReceiveAmountScreen( } val entry = blocktank.createCjit(amountSats = sats.toULong()) - onCjitCreated( - CjitEntryDetails( - networkFeeSat = entry.networkFeeSat.toLong(), - serviceFeeSat = entry.serviceFeeSat.toLong(), - channelSizeSat = entry.channelSizeSat.toLong(), - feeSat = entry.feeSat.toLong(), - receiveAmountSats = sats, - invoice = entry.invoice.request, - ) - ) + onCjitCreated(CjitEntryDetails.from(entry, sats.toULong()).getOrThrow()) }.onFailure { 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) + when { + e is ServiceError.ChannelSizeExceedsMaximum -> { + maxCjitAmountSats = runSuspendCatching { blocktank.maxCjitAmountSats() }.getOrNull() + maxCjitAmountSats?.let { showMaxExceededToast(it) } ?: app.toast(e) + } + !app.toastReceiveCjitError(context, e) -> { + app.toast(e) + } } } isCreatingInvoice = false diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt new file mode 100644 index 0000000000..412f3fffce --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveCjitErrorPresenter.kt @@ -0,0 +1,33 @@ +package to.bitkit.ui.screens.wallets.receive + +import android.content.Context +import to.bitkit.R +import to.bitkit.models.Toast +import to.bitkit.utils.ServiceError +import to.bitkit.viewmodels.AppViewModel + +internal fun AppViewModel.toastReceiveCjitError( + context: Context, + error: Throwable, +): Boolean { + val title: String + val description: String + when (error) { + is ServiceError.CjitQuoteInvalid -> { + title = context.getString(R.string.wallet__receive_cjit_error_invalid__title) + description = context.getString(R.string.wallet__receive_cjit_error_invalid__description) + } + is ServiceError.NodeCapacityUnavailable -> { + title = context.getString(R.string.wallet__receive_cjit_error_node_capacity__title) + description = context.getString(R.string.wallet__receive_cjit_error_node_capacity__description) + } + else -> return false + } + + toast( + type = Toast.ToastType.ERROR, + title = title, + description = description, + ) + return true +} diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt index c1f10c1d2b..26dfa90e49 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveConfirmScreen.kt @@ -21,8 +21,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.synonym.bitkitcore.IcJitEntry import kotlinx.serialization.Serializable import to.bitkit.R +import to.bitkit.models.CjitQuoteValidator import to.bitkit.models.PrimaryDisplay import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.BalanceHeaderView @@ -201,7 +203,26 @@ data class CjitEntryDetails( val feeSat: Long, val receiveAmountSats: Long, val invoice: String, -) +) { + companion object { + fun from(entry: IcJitEntry, receiveAmountSats: ULong): Result { + return CjitQuoteValidator.validate( + invoiceSat = receiveAmountSats, + feeSat = entry.feeSat, + channelSizeSat = entry.channelSizeSat, + ).map { + CjitEntryDetails( + networkFeeSat = entry.networkFeeSat.toLong(), + serviceFeeSat = entry.serviceFeeSat.toLong(), + channelSizeSat = entry.channelSizeSat.toLong(), + feeSat = entry.feeSat.toLong(), + receiveAmountSats = receiveAmountSats.toLong(), + invoice = entry.invoice.request, + ) + } + } + } +} @Preview(showSystemUi = true) @Composable 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 cf21fd640b..5a9e04890b 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 @@ -110,18 +110,18 @@ fun ReceiveQrScreen( SetMaxBrightness() val haptic = LocalHapticFeedback.current - val hasUsableChannels = lightningState.channels.any { it.isUsable } - val usableInboundLiquiditySats = remember(lightningState.channels) { - lightningState.channels.filter { it.isUsable }.calculateRemoteBalance() + val hasReadyChannels = lightningState.channels.any { it.isChannelReady } + val readyInboundLiquiditySats = remember(lightningState.channels) { + lightningState.channels.filter { it.isChannelReady }.calculateRemoteBalance() } val canCreateLightningInvoice = remember( - hasUsableChannels, - usableInboundLiquiditySats, + hasReadyChannels, + readyInboundLiquiditySats, walletState.bip21AmountSats, ) { ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = hasUsableChannels, - inboundCapacitySats = usableInboundLiquiditySats, + hasReadyChannels = hasReadyChannels, + inboundCapacitySats = readyInboundLiquiditySats, invoiceAmountSats = walletState.bip21AmountSats, ) } @@ -383,10 +383,8 @@ fun ReceiveQrScreen( qrLogoPainter = painterResource(getQrLogoResource(tab)), onClickEditInvoice = if (tab == ReceiveTab.TREZOR) { onClickHardwareEditInvoice - } else if (cjitInvoice.isNullOrEmpty()) { - { onClickEditInvoice(tab) } } else { - onClickReceiveCjit + { onClickEditInvoice(tab) } }, tab = tab, modifier = Modifier.fillMaxWidth() 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 d8beebbd55..9a2dafa9aa 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 @@ -75,11 +75,13 @@ fun ReceiveSheet( val rootRoute = startRoute.rootRoute() LaunchedEffect(Unit) { editInvoiceAmountViewModel.clearInput() } - LaunchedEffect(startRoute) { navController.navigateToReceiveStart(startRoute) } - - val cjitInvoice = remember { mutableStateOf(null) } - val cjitEntryDetails = remember { mutableStateOf(null) } + val cjitSessionState = remember { ReceiveCjitSessionState() } val invoiceEditState = remember { ReceiveInvoiceEditState() } + + LaunchedEffect(startRoute) { + cjitSessionState.clear() + navController.navigateToReceiveStart(startRoute) + } var editInvoiceSourceTab by remember { mutableStateOf(ReceiveTab.SAVINGS) } var isAdditionalLiquidityAmountEntry by remember { mutableStateOf(false) } val lightningState: LightningState by wallet.lightningState.collectAsStateWithLifecycle() @@ -136,7 +138,7 @@ fun ReceiveSheet( ) { composableWithDefaultTransitions { ReceiveQrScreen( - cjitInvoice = cjitInvoice.value, + cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, onClickReceiveCjit = { @@ -150,11 +152,13 @@ fun ReceiveSheet( onClickEditInvoice = { editInvoiceSourceTab = it invoiceEditState.beginSoftwareEdit(it) + cjitSessionState.beginReceiveEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, onClickHardwareEditInvoice = { editInvoiceSourceTab = ReceiveTab.TREZOR invoiceEditState.beginHardwareEdit() + cjitSessionState.beginReceiveEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, initialTab = invoiceEditState.initialTab(hardwareWalletId), @@ -260,7 +264,7 @@ fun ReceiveSheet( composableWithDefaultTransitions { ReceiveAmountScreen( onCjitCreated = { entry -> - cjitEntryDetails.value = entry + cjitSessionState.onCjitCreated(entry) navController.navigateTo( if (isAdditionalLiquidityAmountEntry) { ReceiveRoute.ConfirmIncreaseInbound @@ -279,12 +283,12 @@ fun ReceiveSheet( ) } composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> + cjitSessionState.entryDetails?.let { entryDetails -> ReceiveConfirmScreen( entry = entryDetails, onLearnMore = { navController.navigateTo(ReceiveRoute.Liquidity) }, onContinue = { invoice -> - cjitInvoice.value = invoice + cjitSessionState.onCjitConfirmed(invoice) navController.navigateTo( ReceiveRoute.QR ) { popUpTo(ReceiveRoute.QR) { inclusive = true } } @@ -294,12 +298,12 @@ fun ReceiveSheet( } } composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> + cjitSessionState.entryDetails?.let { entryDetails -> ReceiveConfirmScreen( entry = entryDetails, onLearnMore = { navController.navigateTo(ReceiveRoute.LiquidityAdditional) }, onContinue = { invoice -> - cjitInvoice.value = invoice + cjitSessionState.onCjitConfirmed(invoice) navController.navigateTo( ReceiveRoute.QR ) { popUpTo(ReceiveRoute.QR) { inclusive = true } } @@ -310,7 +314,7 @@ fun ReceiveSheet( } } composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> + cjitSessionState.entryDetails?.let { entryDetails -> val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val onNotificationSwitchClick = rememberNotificationToggleClick( @@ -329,7 +333,7 @@ fun ReceiveSheet( } } composableWithDefaultTransitions { - cjitEntryDetails.value?.let { entryDetails -> + cjitSessionState.entryDetails?.let { entryDetails -> val context = LocalContext.current val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() val onNotificationSwitchClick = rememberNotificationToggleClick( @@ -374,7 +378,7 @@ fun ReceiveSheet( navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> - cjitEntryDetails.value = entry + cjitSessionState.onCjitCreated(entry) navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) }, onchainOnly = invoiceEditState.isHardwareInvoice, @@ -418,6 +422,32 @@ fun ReceiveSheet( } } +@Stable +internal class ReceiveCjitSessionState { + var cjitInvoice by mutableStateOf(null) + private set + var entryDetails by mutableStateOf(null) + private set + + fun beginReceiveEdit() { + clear() + } + + fun onCjitCreated(entry: CjitEntryDetails) { + cjitInvoice = null + entryDetails = entry + } + + fun onCjitConfirmed(invoice: String) { + cjitInvoice = invoice + } + + fun clear() { + cjitInvoice = null + entryDetails = null + } +} + @Stable internal class ReceiveInvoiceEditState { var isHardwareInvoice by mutableStateOf(false) diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index 7b144e2272..53ac5cf420 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -23,8 +23,10 @@ sealed class ServiceError(message: String) : AppError(message) { class CurrencyRateUnavailable : ServiceError("Currency rate unavailable") class BlocktankInfoUnavailable : ServiceError("Blocktank info not available") class ChannelSizeExceedsMaximum : ServiceError("Channel size exceeds maximum") + class CjitQuoteInvalid : ServiceError("CJIT quote is invalid") class GeoBlocked : ServiceError("Geo blocked user") class GiftClaimPaymentNotReceived : ServiceError("Gift claim payment not received") + class NodeCapacityUnavailable : ServiceError("Additional spending capacity is unavailable") } class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d6db4c959..80e2fbddc7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1341,8 +1341,12 @@ Receive Lightning funds Receive Bitcoin Bitcoin invoice + The liquidity quote is no longer valid. Try again to get a fresh quote. + Quote Unavailable The maximum you can receive to your spending balance right now is ₿ {amount}. Receiving Capacity Maximum + Additional spending capacity is unavailable right now. Try again later or contact support if this keeps happening. + Spending Capacity Unavailable 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 diff --git a/app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt b/app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt new file mode 100644 index 0000000000..26a64af3df --- /dev/null +++ b/app/src/test/java/to/bitkit/models/CjitQuoteValidatorTest.kt @@ -0,0 +1,52 @@ +package to.bitkit.models + +import org.junit.Test +import to.bitkit.utils.ServiceError +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CjitQuoteValidatorTest { + @Test + fun `rejects fee equal to invoice amount`() { + val result = CjitQuoteValidator.validate( + invoiceSat = 10_000u, + feeSat = 10_000u, + channelSizeSat = 20_000u, + ) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `rejects fee greater than invoice amount`() { + val result = CjitQuoteValidator.validate( + invoiceSat = 10_000u, + feeSat = 10_001u, + channelSizeSat = 20_000u, + ) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `rejects net receive amount greater than channel size`() { + val result = CjitQuoteValidator.validate( + invoiceSat = 10_000u, + feeSat = 1_000u, + channelSizeSat = 8_999u, + ) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `accepts valid quote`() { + val result = CjitQuoteValidator.validate( + invoiceSat = 10_000u, + feeSat = 1_000u, + channelSizeSat = 9_000u, + ) + + assertTrue(result.isSuccess) + } +} diff --git a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt index 331cf23710..1275ce35b4 100644 --- a/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt +++ b/app/src/test/java/to/bitkit/models/ReceiveLiquidityDecisionTest.kt @@ -17,10 +17,10 @@ class ReceiveLiquidityDecisionTest { ) @Test - fun `lightning invoice requires usable channel`() { + fun `lightning invoice requires ready channel`() { assertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = false, + hasReadyChannels = false, inboundCapacitySats = 1_000u, invoiceAmountSats = null, ) @@ -31,7 +31,7 @@ class ReceiveLiquidityDecisionTest { fun `variable lightning invoice requires non-zero inbound liquidity`() { assertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = true, + hasReadyChannels = true, inboundCapacitySats = 0u, invoiceAmountSats = null, ) @@ -39,7 +39,7 @@ class ReceiveLiquidityDecisionTest { assertTrue( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = true, + hasReadyChannels = true, inboundCapacitySats = 1u, invoiceAmountSats = null, ) @@ -50,7 +50,7 @@ class ReceiveLiquidityDecisionTest { fun `fixed lightning invoice requires inbound liquidity covering amount`() { assertTrue( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = true, + hasReadyChannels = true, inboundCapacitySats = 5_000u, invoiceAmountSats = 5_000u, ) @@ -58,7 +58,7 @@ class ReceiveLiquidityDecisionTest { assertFalse( ReceiveLiquidityDecision.canCreateLightningInvoice( - hasUsableChannels = true, + hasReadyChannels = true, inboundCapacitySats = 4_999u, invoiceAmountSats = 5_000u, ) diff --git a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt index 2c9cffbcd0..9edb97d2f0 100644 --- a/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BlocktankRepoTest.kt @@ -547,12 +547,12 @@ class BlocktankRepoTest : BaseUnitTest() { } @Test - fun `toCjitError maps node capacity limit to max channel size error`() { + fun `toCjitError maps node capacity limit to node capacity error`() { val error = RuntimeException("Node capacity is above our capacity limit.") val result = error.toCjitError() - assertIs(result) + assertIs(result) } @Test diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index 4cbf4f91b3..c59e370d33 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -330,26 +330,26 @@ class WalletRepoTest : BaseUnitTest() { } @Test - fun `updateBip21Invoice should not create bolt11 when channels are ready but not usable`() = test { + fun `updateBip21Invoice should create bolt11 when channels are ready but not usable`() = test { whenever(lightningRepo.lightningState) .thenReturn(MutableStateFlow(LightningState(channels = readyButNotUsableChannels))) whenever(lightningRepo.getChannels()).thenReturn(readyButNotUsableChannels) + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) sut.updateBip21Invoice(amountSats = SATS, description = "test").let { result -> assertTrue(result.isSuccess) - assertEquals("", sut.walletState.value.bolt11) + assertEquals(INVOICE, sut.walletState.value.bolt11) } - verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) } @Test - fun `inboundLiquiditySats should only count usable channels`() = test { + fun `inboundLiquiditySats should count ready 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()) + assertEquals(2_000uL, sut.inboundLiquiditySats()) } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt new file mode 100644 index 0000000000..5f4e634271 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/receive/CjitEntryDetailsTest.kt @@ -0,0 +1,49 @@ +package to.bitkit.ui.screens.wallets.receive + +import com.synonym.bitkitcore.IcJitEntry +import org.junit.Test +import to.bitkit.ext.mock +import to.bitkit.utils.ServiceError +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class CjitEntryDetailsTest { + @Test + fun `from rejects fee equal to invoice amount`() { + val entry = IcJitEntry.mock(feeSat = 10_000u, channelSizeSat = 20_000u) + + val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `from rejects fee greater than invoice amount`() { + val entry = IcJitEntry.mock(feeSat = 10_001u, channelSizeSat = 20_000u) + + val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `from rejects net receive amount greater than channel size`() { + val entry = IcJitEntry.mock(feeSat = 1_000u, channelSizeSat = 8_999u) + + val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u) + + assertIs(result.exceptionOrNull()) + } + + @Test + fun `from maps valid quote`() { + val entry = IcJitEntry.mock(feeSat = 1_000u, channelSizeSat = 20_000u) + + val result = CjitEntryDetails.from(entry, receiveAmountSats = 10_000u).getOrThrow() + + assertEquals(10_000, result.receiveAmountSats) + assertEquals(1_000, result.feeSat) + assertEquals(20_000, result.channelSizeSat) + assertEquals(entry.invoice.request, result.invoice) + } +} 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 index 629058fa48..a9bf71cf78 100644 --- 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 @@ -40,4 +40,52 @@ class ReceiveInvoiceEditStateTest { assertNull(state.initialTab(hardwareWalletId = null)) } + + @Test + fun `receive CJIT session clears stale invoice when editing starts`() { + val state = ReceiveCjitSessionState() + val entry = cjitEntryDetails(invoice = "first") + + state.onCjitCreated(entry) + state.onCjitConfirmed("first") + state.beginReceiveEdit() + + assertNull(state.cjitInvoice) + assertNull(state.entryDetails) + } + + @Test + fun `receive CJIT session clears old invoice when fresh CJIT is created`() { + val state = ReceiveCjitSessionState() + val first = cjitEntryDetails(invoice = "first") + val second = cjitEntryDetails(invoice = "second") + + state.onCjitCreated(first) + state.onCjitConfirmed("first") + state.onCjitCreated(second) + + assertNull(state.cjitInvoice) + assertEquals(second, state.entryDetails) + } + + @Test + fun `receive CJIT session exposes confirmed fresh invoice`() { + val state = ReceiveCjitSessionState() + val entry = cjitEntryDetails(invoice = "fresh") + + state.onCjitCreated(entry) + state.onCjitConfirmed("fresh") + + assertEquals("fresh", state.cjitInvoice) + assertEquals(entry, state.entryDetails) + } + + private fun cjitEntryDetails(invoice: String) = CjitEntryDetails( + networkFeeSat = 1, + serviceFeeSat = 1, + channelSizeSat = 10_000, + feeSat = 2, + receiveAmountSats = 1_000, + invoice = invoice, + ) } diff --git a/changelog.d/next/receive-liquidity-cjit.fixed.md b/changelog.d/next/receive-liquidity-cjit.fixed.md new file mode 100644 index 0000000000..e64b3d3612 --- /dev/null +++ b/changelog.d/next/receive-liquidity-cjit.fixed.md @@ -0,0 +1 @@ +Receiving over Lightning now refreshes liquidity requests correctly and avoids showing stale or invalid CJIT quotes. diff --git a/docs/receive-liquidity.md b/docs/receive-liquidity.md index fd12b2986b..9407536470 100644 --- a/docs/receive-liquidity.md +++ b/docs/receive-liquidity.md @@ -1,14 +1,15 @@ # 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. +This document describes how the receive flow decides whether to show a normal Lightning invoice or send the user into CJIT liquidity setup. ## Cases -- Opening the Receive sheet: - - A new Receive sheet session starts from a fresh tab state. +- Opening Receive: + - A new receive 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. + - Temporary receive-session state, such as the selected tab, nested navigation, pending CJIT details, and CJIT invoice QR state, must not + survive closing and reopening Receive. - Editing from Savings or Auto: - Editing sets the amount for the receive request. @@ -21,7 +22,7 @@ This document describes how the receive flow decides whether to show a normal Li - Returning from the edit flow preserves the hardware receive tab when the edit originated there. - Editing from Savings or Auto while a hardware wallet is available still returns to the source tab, not the hardware tab. -- Lightning receive unavailable because there is no usable channel or usable inbound liquidity is `0`: +- Lightning receive unavailable because there is no ready channel or ready inbound liquidity is `0`: - No Lightning invoice is created. - The normal QR remains Savings/onchain only. - The Spending tab shows CJIT onboarding. @@ -29,28 +30,31 @@ This document describes how the receive flow decides whether to show a normal Li - Editing from Savings or Auto updates the receive amount and returns to the normal QR; it does not create or route to CJIT. - When a channel already exists, later CJIT confirmation and learn-more screens use additional-liquidity copy. -- Usable channel, inbound liquidity greater than `0`, zero/variable amount: +- 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. -- Usable channel, fixed amount less than or equal to inbound liquidity: +- 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. -- Usable channel, fixed amount greater than inbound liquidity but below CJIT minimum: +- 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. -- Usable channel, fixed amount greater than inbound liquidity and at or above CJIT minimum: +- 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. + - Editing from a CJIT Lightning invoice QR must replace the previously displayed CJIT invoice before showing the updated receive result, + because the previous LSP invoice is immutable. - 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`. + - If Blocktank rejects additional CJIT because the node is already at its total capacity limit, the app explains that additional spending capacity is unavailable instead of showing the per-channel maximum. - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. - Geo-blocked and liquidity is needed: @@ -60,5 +64,11 @@ This document describes how the receive flow decides whether to show a normal Li ## Invariants - Auto tab availability and default tab selection are based on whether a normal Lightning invoice can be created for the current receive amount. -- Ready channels alone do not imply Auto availability; the channel must be usable, and fixed receive amounts must fit within usable inbound liquidity. +- Ready channels alone do not imply Auto availability; fixed receive amounts must also fit within ready 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. +- Before displaying a CJIT confirmation, the app must reject quotes where `feeSat >= invoiceSat` or + `channelSizeSat < invoiceSat - feeSat`. Invalid quotes must show a user-facing error and must never produce a negative receive amount. + +## Platform Differences + +No intentional platform differences are currently specified. From 194bf37fa3f1418d6adb542e52c59cdbabcca60f Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 11 Sep 2026 15:05:25 +0200 Subject: [PATCH 2/6] fix: preserve cjit receive edit state --- .../java/to/bitkit/repositories/WalletRepo.kt | 13 ++++++++++++ .../wallets/receive/EditInvoiceScreen.kt | 7 +++++-- .../screens/wallets/receive/ReceiveSheet.kt | 21 +++++++++++-------- .../to/bitkit/viewmodels/WalletViewModel.kt | 10 +++++++++ .../to/bitkit/repositories/WalletRepoTest.kt | 17 +++++++++++++++ .../receive/ReceiveInvoiceEditStateTest.kt | 16 ++++++++++++-- 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index a3e5b4d325..62b9a9a842 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -630,6 +630,19 @@ class WalletRepo @Inject constructor( fun setBip21AmountSats(amount: ULong?) = _walletState.update { it.copy(bip21AmountSats = amount) } + suspend fun updateOnchainBip21Amount(amountSats: ULong?): Result = withContext(bgDispatcher) { + runSuspendCatching { + val normalizedAmount = amountSats?.takeIf { it > 0uL } + setBip21AmountSats(normalizedAmount) + val newBip21 = buildBip21Url( + bitcoinAddress = getOnchainAddress(), + amountSats = normalizedAmount, + message = walletState.value.bip21Description, + ) + setBip21(newBip21) + } + } + fun setBip21Description(description: String) = _walletState.update { it.copy(bip21Description = description) } fun clearBip21State(clearTags: Boolean = true) { 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 47e30669b2..d63aa3a642 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 @@ -130,8 +130,11 @@ fun EditInvoiceScreen( } is ReceiveAdditionalLiquidityAction.CreateCjit -> { isCreatingCjit = true - runSuspendCatching { blocktankVM.createCjit(action.amountSats) }.onSuccess { entry -> - navigateReceiveConfirm(CjitEntryDetails.from(entry, action.amountSats).getOrThrow()) + runSuspendCatching { + val entry = blocktankVM.createCjit(action.amountSats) + CjitEntryDetails.from(entry, action.amountSats).getOrThrow() + }.onSuccess { + navigateReceiveConfirm(it) }.onFailure { Logger.error("Failed to create CJIT invoice", it, context = "EditInvoiceScreen") if (!app.toastReceiveCjitError(context, it) && 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 9a2dafa9aa..00e1a5d66b 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 @@ -15,6 +15,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -26,6 +27,7 @@ import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.models.PubkyPublicKeyFormat @@ -73,6 +75,7 @@ fun ReceiveSheet( val wallet = requireNotNull(walletViewModel) val navController = rememberNavController() val rootRoute = startRoute.rootRoute() + val scope = rememberCoroutineScope() LaunchedEffect(Unit) { editInvoiceAmountViewModel.clearInput() } val cjitSessionState = remember { ReceiveCjitSessionState() } @@ -152,13 +155,11 @@ fun ReceiveSheet( onClickEditInvoice = { editInvoiceSourceTab = it invoiceEditState.beginSoftwareEdit(it) - cjitSessionState.beginReceiveEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, onClickHardwareEditInvoice = { editInvoiceSourceTab = ReceiveTab.TREZOR invoiceEditState.beginHardwareEdit() - cjitSessionState.beginReceiveEdit() navController.navigateTo(ReceiveRoute.EditInvoice) }, initialTab = invoiceEditState.initialTab(hardwareWalletId), @@ -361,7 +362,10 @@ fun ReceiveSheet( lightningState = lightningState, sourceTab = editInvoiceSourceTab, onBack = { navController.popBackStack() }, - updateInvoice = wallet::updateBip21Invoice, + updateInvoice = { + cjitSessionState.clear() + wallet.updateBip21Invoice(it) + }, onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, onClickTag = wallet::removeTag, onDescriptionUpdate = wallet::updateBip21Description, @@ -378,8 +382,11 @@ fun ReceiveSheet( navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> - cjitSessionState.onCjitCreated(entry) - navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) + scope.launch { + wallet.updateOnchainBip21Amount(entry.receiveAmountSats.toULong()) + cjitSessionState.onCjitCreated(entry) + navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) + } }, onchainOnly = invoiceEditState.isHardwareInvoice, updateOnchainInvoice = wallet::setBip21AmountSats, @@ -429,10 +436,6 @@ internal class ReceiveCjitSessionState { var entryDetails by mutableStateOf(null) private set - fun beginReceiveEdit() { - clear() - } - fun onCjitCreated(entry: CjitEntryDetails) { cjitInvoice = null entryDetails = entry diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index aa4a52d69b..99e162da27 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -519,6 +519,16 @@ class WalletViewModel @Inject constructor( } } + suspend fun updateOnchainBip21Amount(amountSats: ULong?) { + walletRepo.updateOnchainBip21Amount(amountSats).onFailure { error -> + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__error_invoice_update), + description = error.message ?: context.getString(R.string.common__error_body) + ) + } + } + fun refreshReceiveState() = viewModelScope.launch { launch { blocktankRepo.refreshInfo() } lightningRepo.syncState() diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c59e370d33..e9741b2844 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -549,6 +549,23 @@ class WalletRepoTest : BaseUnitTest() { assertEquals(SATS, sut.walletState.value.bip21AmountSats) } + @Test + fun `updateOnchainBip21Amount should update amount and bip21 without lightning invoice`() = test { + sut.setOnchainAddress(ADDRESS) + sut.setBip21Description("test") + whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) + + val result = sut.updateOnchainBip21Amount(2000uL) + + assertTrue(result.isSuccess) + assertEquals(2000uL, sut.walletState.value.bip21AmountSats) + assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) + assertTrue(sut.walletState.value.bip21.contains("amount=0.00002")) + assertTrue(sut.walletState.value.bip21.contains("message=test")) + assertFalse(sut.walletState.value.bip21.contains("lightning=")) + verify(lightningRepo, never()).createInvoice(anyOrNull(), any(), any()) + } + @Test fun `setBip21Description should update state`() = test { val testDescription = "test description" 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 index a9bf71cf78..5878434d10 100644 --- 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 @@ -42,13 +42,25 @@ class ReceiveInvoiceEditStateTest { } @Test - fun `receive CJIT session clears stale invoice when editing starts`() { + fun `receive CJIT session keeps invoice when edit is cancelled`() { val state = ReceiveCjitSessionState() val entry = cjitEntryDetails(invoice = "first") state.onCjitCreated(entry) state.onCjitConfirmed("first") - state.beginReceiveEdit() + + assertEquals("first", state.cjitInvoice) + assertEquals(entry, state.entryDetails) + } + + @Test + fun `receive CJIT session clears stale invoice when edit is applied`() { + val state = ReceiveCjitSessionState() + val entry = cjitEntryDetails(invoice = "first") + + state.onCjitCreated(entry) + state.onCjitConfirmed("first") + state.clear() assertNull(state.cjitInvoice) assertNull(state.entryDetails) From 50ff0379f494a8dde3d05eca8428eb6805b52649 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 14 Sep 2026 18:28:24 +0200 Subject: [PATCH 3/6] fix: preserve cjit replacement state --- .../java/to/bitkit/repositories/WalletRepo.kt | 1 + .../screens/wallets/receive/ReceiveSheet.kt | 25 ++++++++++++------- .../to/bitkit/viewmodels/WalletViewModel.kt | 2 +- .../to/bitkit/repositories/WalletRepoTest.kt | 2 ++ .../receive/ReceiveInvoiceEditStateTest.kt | 9 +++++-- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 62b9a9a842..80051eddfe 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -634,6 +634,7 @@ class WalletRepo @Inject constructor( runSuspendCatching { val normalizedAmount = amountSats?.takeIf { it > 0uL } setBip21AmountSats(normalizedAmount) + setBolt11("") val newBip21 = buildBip21Url( bitcoinAddress = getOnchainAddress(), amountSats = normalizedAmount, 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 00e1a5d66b..7aa5e3b8a6 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 @@ -15,7 +15,6 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -27,7 +26,6 @@ import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.R import to.bitkit.models.PubkyPublicKeyFormat @@ -36,6 +34,7 @@ import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestDraft import to.bitkit.repositories.PaykitPaymentRequestTarget import to.bitkit.repositories.WalletState +import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.ConnectionIssuesView import to.bitkit.ui.navigateTo import to.bitkit.ui.openNotificationSettings @@ -75,7 +74,7 @@ fun ReceiveSheet( val wallet = requireNotNull(walletViewModel) val navController = rememberNavController() val rootRoute = startRoute.rootRoute() - val scope = rememberCoroutineScope() + val currencies = LocalCurrencies.current LaunchedEffect(Unit) { editInvoiceAmountViewModel.clearInput() } val cjitSessionState = remember { ReceiveCjitSessionState() } @@ -119,6 +118,15 @@ fun ReceiveSheet( var skipPaymentRequestAmount by remember { mutableStateOf(false) } var isEditingPaymentRequestAmount by remember { mutableStateOf(false) } + fun resetEditInvoiceAmount() { + val amountSats = walletState.bip21AmountSats + if (amountSats == null || amountSats == 0uL) { + editInvoiceAmountViewModel.clearInput() + } else { + editInvoiceAmountViewModel.setSats(amountSats.toLong(), currencies) + } + } + LaunchedEffect(Unit) { wallet.resetPreActivityMetadataTagsForCurrentInvoice() wallet.refreshReceiveState() @@ -155,11 +163,13 @@ fun ReceiveSheet( onClickEditInvoice = { editInvoiceSourceTab = it invoiceEditState.beginSoftwareEdit(it) + resetEditInvoiceAmount() navController.navigateTo(ReceiveRoute.EditInvoice) }, onClickHardwareEditInvoice = { editInvoiceSourceTab = ReceiveTab.TREZOR invoiceEditState.beginHardwareEdit() + resetEditInvoiceAmount() navController.navigateTo(ReceiveRoute.EditInvoice) }, initialTab = invoiceEditState.initialTab(hardwareWalletId), @@ -304,6 +314,7 @@ fun ReceiveSheet( entry = entryDetails, onLearnMore = { navController.navigateTo(ReceiveRoute.LiquidityAdditional) }, onContinue = { invoice -> + wallet.updateOnchainBip21Amount(entryDetails.receiveAmountSats.toULong()) cjitSessionState.onCjitConfirmed(invoice) navController.navigateTo( ReceiveRoute.QR @@ -382,11 +393,8 @@ fun ReceiveSheet( navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> - scope.launch { - wallet.updateOnchainBip21Amount(entry.receiveAmountSats.toULong()) - cjitSessionState.onCjitCreated(entry) - navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) - } + cjitSessionState.onCjitCreated(entry) + navController.navigateTo(ReceiveRoute.ConfirmIncreaseInbound) }, onchainOnly = invoiceEditState.isHardwareInvoice, updateOnchainInvoice = wallet::setBip21AmountSats, @@ -437,7 +445,6 @@ internal class ReceiveCjitSessionState { private set fun onCjitCreated(entry: CjitEntryDetails) { - cjitInvoice = null entryDetails = entry } diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 99e162da27..ff1c5b36a2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -519,7 +519,7 @@ class WalletViewModel @Inject constructor( } } - suspend fun updateOnchainBip21Amount(amountSats: ULong?) { + fun updateOnchainBip21Amount(amountSats: ULong?) = viewModelScope.launch { walletRepo.updateOnchainBip21Amount(amountSats).onFailure { error -> ToastEventBus.send( type = Toast.ToastType.ERROR, diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e9741b2844..a259ec5550 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -552,6 +552,7 @@ class WalletRepoTest : BaseUnitTest() { @Test fun `updateOnchainBip21Amount should update amount and bip21 without lightning invoice`() = test { sut.setOnchainAddress(ADDRESS) + sut.setBolt11(INVOICE) sut.setBip21Description("test") whenever(lightningRepo.createInvoice(anyOrNull(), any(), any())).thenReturn(Result.success(INVOICE)) @@ -559,6 +560,7 @@ class WalletRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(2000uL, sut.walletState.value.bip21AmountSats) + assertEquals("", sut.walletState.value.bolt11) assertTrue(sut.walletState.value.bip21.contains(ADDRESS)) assertTrue(sut.walletState.value.bip21.contains("amount=0.00002")) assertTrue(sut.walletState.value.bip21.contains("message=test")) 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 index 5878434d10..fe10d6070b 100644 --- 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 @@ -67,7 +67,7 @@ class ReceiveInvoiceEditStateTest { } @Test - fun `receive CJIT session clears old invoice when fresh CJIT is created`() { + fun `receive CJIT session keeps old invoice until fresh CJIT is confirmed`() { val state = ReceiveCjitSessionState() val first = cjitEntryDetails(invoice = "first") val second = cjitEntryDetails(invoice = "second") @@ -76,7 +76,12 @@ class ReceiveInvoiceEditStateTest { state.onCjitConfirmed("first") state.onCjitCreated(second) - assertNull(state.cjitInvoice) + assertEquals("first", state.cjitInvoice) + assertEquals(second, state.entryDetails) + + state.onCjitConfirmed("second") + + assertEquals("second", state.cjitInvoice) assertEquals(second, state.entryDetails) } From 99d2f4603a1f2c006c00143e52c20f932bb11005 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 15 Sep 2026 12:17:20 +0200 Subject: [PATCH 4/6] fix: sync initial cjit amount --- .../java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt | 1 + 1 file changed, 1 insertion(+) 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 7aa5e3b8a6..d9ce8e160f 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 @@ -299,6 +299,7 @@ fun ReceiveSheet( entry = entryDetails, onLearnMore = { navController.navigateTo(ReceiveRoute.Liquidity) }, onContinue = { invoice -> + wallet.updateOnchainBip21Amount(entryDetails.receiveAmountSats.toULong()) cjitSessionState.onCjitConfirmed(invoice) navController.navigateTo( ReceiveRoute.QR From d47254769a016d69c67dc3c58e9943268849a693 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 15 Sep 2026 12:25:46 +0200 Subject: [PATCH 5/6] fix: preserve cjit on unchanged edit --- .../ui/screens/wallets/receive/ReceiveSheet.kt | 10 ++++++++-- .../receive/ReceiveInvoiceEditStateTest.kt | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) 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 d9ce8e160f..8767165370 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 @@ -375,8 +375,10 @@ fun ReceiveSheet( sourceTab = editInvoiceSourceTab, onBack = { navController.popBackStack() }, updateInvoice = { - cjitSessionState.clear() - wallet.updateBip21Invoice(it) + if (!cjitSessionState.hasConfirmedInvoiceForAmount(it)) { + cjitSessionState.clear() + wallet.updateBip21Invoice(it) + } }, onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, onClickTag = wallet::removeTag, @@ -453,6 +455,10 @@ internal class ReceiveCjitSessionState { cjitInvoice = invoice } + fun hasConfirmedInvoiceForAmount(amountSats: ULong?): Boolean { + return cjitInvoice != null && entryDetails?.receiveAmountSats?.toULong() == amountSats + } + fun clear() { cjitInvoice = null entryDetails = null 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 index fe10d6070b..b5bf0310c9 100644 --- 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 @@ -97,6 +97,22 @@ class ReceiveInvoiceEditStateTest { assertEquals(entry, state.entryDetails) } + @Test + fun `receive CJIT session matches confirmed invoice amount`() { + val state = ReceiveCjitSessionState() + val entry = cjitEntryDetails(invoice = "fresh") + + state.onCjitCreated(entry) + + assertFalse(state.hasConfirmedInvoiceForAmount(1_000uL)) + + state.onCjitConfirmed("fresh") + + assertTrue(state.hasConfirmedInvoiceForAmount(1_000uL)) + assertFalse(state.hasConfirmedInvoiceForAmount(2_000uL)) + assertFalse(state.hasConfirmedInvoiceForAmount(null)) + } + private fun cjitEntryDetails(invoice: String) = CjitEntryDetails( networkFeeSat = 1, serviceFeeSat = 1, From cb0904585c4ebfb550d459434ee37969c3fec18e Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 15 Sep 2026 17:02:51 +0200 Subject: [PATCH 6/6] fix: preserve confirmed cjit amount --- .../screens/wallets/receive/ReceiveSheet.kt | 7 +++++- .../receive/ReceiveInvoiceEditStateTest.kt | 23 ++++++++++++++++--- docs/receive-liquidity.md | 4 +++- 3 files changed, 29 insertions(+), 5 deletions(-) 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 8767165370..b4036546fa 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 @@ -378,6 +378,8 @@ fun ReceiveSheet( if (!cjitSessionState.hasConfirmedInvoiceForAmount(it)) { cjitSessionState.clear() wallet.updateBip21Invoice(it) + } else { + wallet.updateOnchainBip21Amount(it) } }, onClickAddTag = { navController.navigateTo(ReceiveRoute.AddTag) }, @@ -446,6 +448,7 @@ internal class ReceiveCjitSessionState { private set var entryDetails by mutableStateOf(null) private set + private var confirmedAmountSats by mutableStateOf(null) fun onCjitCreated(entry: CjitEntryDetails) { entryDetails = entry @@ -453,15 +456,17 @@ internal class ReceiveCjitSessionState { fun onCjitConfirmed(invoice: String) { cjitInvoice = invoice + confirmedAmountSats = entryDetails?.receiveAmountSats?.toULong() } fun hasConfirmedInvoiceForAmount(amountSats: ULong?): Boolean { - return cjitInvoice != null && entryDetails?.receiveAmountSats?.toULong() == amountSats + return cjitInvoice != null && confirmedAmountSats == amountSats } fun clear() { cjitInvoice = null entryDetails = null + confirmedAmountSats = null } } 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 index b5bf0310c9..bed4b6406a 100644 --- 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 @@ -100,7 +100,7 @@ class ReceiveInvoiceEditStateTest { @Test fun `receive CJIT session matches confirmed invoice amount`() { val state = ReceiveCjitSessionState() - val entry = cjitEntryDetails(invoice = "fresh") + val entry = cjitEntryDetails(invoice = "fresh", receiveAmountSats = 1_000) state.onCjitCreated(entry) @@ -113,12 +113,29 @@ class ReceiveInvoiceEditStateTest { assertFalse(state.hasConfirmedInvoiceForAmount(null)) } - private fun cjitEntryDetails(invoice: String) = CjitEntryDetails( + @Test + fun `receive CJIT session matches confirmed amount when pending quote differs`() { + val state = ReceiveCjitSessionState() + val first = cjitEntryDetails(invoice = "first", receiveAmountSats = 1_000) + val second = cjitEntryDetails(invoice = "second", receiveAmountSats = 2_000) + + state.onCjitCreated(first) + state.onCjitConfirmed("first") + state.onCjitCreated(second) + + assertTrue(state.hasConfirmedInvoiceForAmount(1_000uL)) + assertFalse(state.hasConfirmedInvoiceForAmount(2_000uL)) + } + + private fun cjitEntryDetails( + invoice: String, + receiveAmountSats: Long = 1_000, + ) = CjitEntryDetails( networkFeeSat = 1, serviceFeeSat = 1, channelSizeSat = 10_000, feeSat = 2, - receiveAmountSats = 1_000, + receiveAmountSats = receiveAmountSats, invoice = invoice, ) } diff --git a/docs/receive-liquidity.md b/docs/receive-liquidity.md index 9407536470..b3fcaa3c45 100644 --- a/docs/receive-liquidity.md +++ b/docs/receive-liquidity.md @@ -51,11 +51,13 @@ This document describes how the receive flow decides whether to show a normal Li - The CJIT Lightning invoice is an invoice to the LSP and must be shown as Spending-only, not as Auto/unified receive. - Editing from a CJIT Lightning invoice QR must replace the previously displayed CJIT invoice before showing the updated receive result, because the previous LSP invoice is immutable. + - Re-applying an edit at the confirmed CJIT amount keeps the current CJIT invoice and refreshes on-chain metadata such as the note. + - Editing from a CJIT Lightning invoice QR to a different amount clears or replaces the current CJIT invoice before showing the updated receive result. - 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`. - If Blocktank rejects additional CJIT because the node is already at its total capacity limit, the app explains that additional spending capacity is unavailable instead of showing the per-channel maximum. - - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. + - Editing from Savings or Auto returns to the normal QR with Savings/onchain only, except unchanged edits at the confirmed CJIT amount preserve the current CJIT invoice. - Geo-blocked and liquidity is needed: - The flow routes to the CJIT geo-block screen.