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..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) { @@ -756,14 +769,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 +785,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..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 @@ -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() @@ -128,20 +130,16 @@ 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, - ) - ) + 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 (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..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,13 +75,16 @@ fun ReceiveSheet( val wallet = requireNotNull(walletViewModel) val navController = rememberNavController() val rootRoute = startRoute.rootRoute() + val scope = rememberCoroutineScope() 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 +141,7 @@ fun ReceiveSheet( ) { composableWithDefaultTransitions { ReceiveQrScreen( - cjitInvoice = cjitInvoice.value, + cjitInvoice = cjitSessionState.cjitInvoice, walletState = walletState, lightningState = lightningState, onClickReceiveCjit = { @@ -260,7 +265,7 @@ fun ReceiveSheet( composableWithDefaultTransitions { ReceiveAmountScreen( onCjitCreated = { entry -> - cjitEntryDetails.value = entry + cjitSessionState.onCjitCreated(entry) navController.navigateTo( if (isAdditionalLiquidityAmountEntry) { ReceiveRoute.ConfirmIncreaseInbound @@ -279,12 +284,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 +299,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 +315,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 +334,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( @@ -357,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, @@ -374,8 +382,11 @@ fun ReceiveSheet( navController.navigateTo(ReceiveRoute.PaymentRequestRecipient) }, navigateReceiveConfirm = { entry -> - cjitEntryDetails.value = 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, @@ -418,6 +429,28 @@ fun ReceiveSheet( } } +@Stable +internal class ReceiveCjitSessionState { + var cjitInvoice by mutableStateOf(null) + private set + var entryDetails by mutableStateOf(null) + private set + + 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/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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b8d50eb58..7a02bba920 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1309,8 +1309,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..e9741b2844 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 @@ -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/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..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 @@ -40,4 +40,64 @@ class ReceiveInvoiceEditStateTest { assertNull(state.initialTab(hardwareWalletId = null)) } + + @Test + fun `receive CJIT session keeps invoice when edit is cancelled`() { + val state = ReceiveCjitSessionState() + val entry = cjitEntryDetails(invoice = "first") + + state.onCjitCreated(entry) + state.onCjitConfirmed("first") + + 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) + } + + @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.