From 282da3fa936f16780b5b3554f519f81c0d14a74c Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Thu, 3 Sep 2026 12:40:36 +0200 Subject: [PATCH] fix(receive): handle additional receive liquidity edge cases --- Bitkit/Components/CopyAddressCard.swift | 3 +- Bitkit/Models/ReceiveLiquidityDecision.swift | 98 +++++++++ .../Localization/en.lproj/Localizable.strings | 4 +- Bitkit/ViewModels/BlocktankViewModel.swift | 50 +++++ Bitkit/ViewModels/SheetViewModel.swift | 3 +- Bitkit/ViewModels/TransferViewModel.swift | 6 - Bitkit/ViewModels/WalletViewModel.swift | 20 +- Bitkit/Views/Wallets/Receive/QrArea.swift | 3 +- .../Wallets/Receive/ReceiveCjitAmount.swift | 75 +++++++ .../Receive/ReceiveCjitConfirmation.swift | 9 +- .../Receive/ReceiveCjitLearnMore.swift | 17 +- .../Views/Wallets/Receive/ReceiveEdit.swift | 76 ++++--- Bitkit/Views/Wallets/Receive/ReceiveQr.swift | 58 +++-- .../Views/Wallets/Receive/ReceiveSheet.swift | 13 +- .../ReceiveLiquidityDecisionTests.swift | 202 ++++++++++++++++++ BitkitTests/ReceiveSheetSessionTests.swift | 20 ++ Docs/receive-liquidity.md | 59 +++++ 17 files changed, 644 insertions(+), 72 deletions(-) create mode 100644 Bitkit/Models/ReceiveLiquidityDecision.swift create mode 100644 BitkitTests/ReceiveLiquidityDecisionTests.swift create mode 100644 BitkitTests/ReceiveSheetSessionTests.swift create mode 100644 Docs/receive-liquidity.md diff --git a/Bitkit/Components/CopyAddressCard.swift b/Bitkit/Components/CopyAddressCard.swift index 659bbcc25..e2b2cb976 100644 --- a/Bitkit/Components/CopyAddressCard.swift +++ b/Bitkit/Components/CopyAddressCard.swift @@ -13,6 +13,7 @@ struct CopyAddressPair { struct CopyAddressCard: View { let addresses: [CopyAddressPair] + let sourceTab: ReceiveQr.ReceiveTab @Binding var navigationPath: [ReceiveRoute] @State private var showTooltipForIndex: Int? = nil @@ -38,7 +39,7 @@ struct CopyAddressCard: View { icon: Image("pencil").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent), shouldExpand: true ) { - navigationPath.append(.edit) + navigationPath.append(.edit(tab: sourceTab)) } CustomButton( diff --git a/Bitkit/Models/ReceiveLiquidityDecision.swift b/Bitkit/Models/ReceiveLiquidityDecision.swift new file mode 100644 index 000000000..7e959e062 --- /dev/null +++ b/Bitkit/Models/ReceiveLiquidityDecision.swift @@ -0,0 +1,98 @@ +enum ReceiveAdditionalLiquidityAction: Equatable { + case none + case chooseAmount + case createCjit(UInt64) + case geoBlocked +} + +enum ReceiveLiquiditySource: Equatable { + case savings + case auto + case spending +} + +enum ReceiveLiquidityDecision { + static func canCreateLightningInvoice( + hasReadyChannels: Bool, + inboundCapacitySats: UInt64?, + invoiceAmountSats: UInt64? + ) -> Bool { + guard hasReadyChannels, let inboundCapacitySats else { + return false + } + + guard let invoiceAmountSats, invoiceAmountSats > 0 else { + return inboundCapacitySats > 0 + } + + return invoiceAmountSats <= inboundCapacitySats + } + + static func additionalLiquidityAction( + source: ReceiveLiquiditySource, + invoiceAmountSats: UInt64, + inboundCapacitySats: UInt64?, + minCjitSats: UInt64?, + maxCjitAmountSats: UInt64?, + isGeoBlocked: Bool + ) -> ReceiveAdditionalLiquidityAction { + guard source == .spending else { + return .none + } + + guard needsInboundLiquidity(invoiceAmountSats: invoiceAmountSats, inboundCapacitySats: inboundCapacitySats) else { + return .none + } + + let inboundCapacitySats = inboundCapacitySats ?? 0 + if inboundCapacitySats == 0 { + return .none + } + + if isGeoBlocked { + return .geoBlocked + } + + let minCjitSats = minCjitSats ?? 0 + guard let maxCjitAmountSats, maxCjitAmountSats > 0 else { + return .chooseAmount + } + + if invoiceAmountSats == 0 || minCjitSats == 0 || invoiceAmountSats < minCjitSats || invoiceAmountSats > maxCjitAmountSats { + return .chooseAmount + } + + return .createCjit(invoiceAmountSats) + } + + static func needsCjitLimitsForAdditionalLiquidity( + source: ReceiveLiquiditySource, + invoiceAmountSats: UInt64, + inboundCapacitySats: UInt64?, + isGeoBlocked: Bool + ) -> Bool { + guard source == .spending else { + return false + } + + guard needsInboundLiquidity(invoiceAmountSats: invoiceAmountSats, inboundCapacitySats: inboundCapacitySats) else { + return false + } + + guard (inboundCapacitySats ?? 0) > 0 else { + return false + } + + return !isGeoBlocked + } + + static func needsInboundLiquidity(invoiceAmountSats: UInt64, inboundCapacitySats: UInt64?) -> Bool { + let inboundCapacitySats = inboundCapacitySats ?? 0 + + if invoiceAmountSats == 0 { + return inboundCapacitySats == 0 + } + + return invoiceAmountSats > inboundCapacitySats + } +} diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 8444bbee5..f0afaaa18 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1359,7 +1359,7 @@ "wallet__receive_bitcoin_invoice" = "Bitcoin invoice"; "wallet__receive_lightning_invoice" = "Lightning invoice"; "wallet__receive_note_placeholder" = "Optional note to payer"; -"wallet__receive_show_qr" = "Show QR Code"; +"wallet__receive_show_qr" = "QR Code"; "wallet__receive_text_lnfunds" = "Want to receive Lightning funds?"; "wallet__receive_background_setup_text" = "Enable background setup to safely exit Bitkit while your balance is being configured."; "wallet__receive_background_setup_toggle" = "Enable background setup"; @@ -1376,6 +1376,8 @@ "wallet__receive_liquidity__label_additional" = "Additional Spending Balance Liquidity"; "wallet__receive_cjit_error" = "Transaction Failed"; "wallet__receive_cjit_error_msg" = "Failed to send funds to your spending account."; +"wallet__receive_cjit_error_max__title" = "Receiving Capacity Maximum"; +"wallet__receive_cjit_error_max__description" = "The maximum you can receive to your spending balance right now is ₿ {amount}."; "wallet__receive_will" = "You will receive"; "wallet__receive_ldk_init" = "Spending Balance Initializing..."; "wallet__minimum" = "MINIMUM"; diff --git a/Bitkit/ViewModels/BlocktankViewModel.swift b/Bitkit/ViewModels/BlocktankViewModel.swift index 1c9f197c0..26423b991 100644 --- a/Bitkit/ViewModels/BlocktankViewModel.swift +++ b/Bitkit/ViewModels/BlocktankViewModel.swift @@ -119,8 +119,17 @@ class BlocktankViewModel: ObservableObject { } let lspBalance = try await getDefaultLspBalance(clientBalance: amountSats) + guard amountSats <= UInt64.max - lspBalance else { + throw CustomServiceError.channelSizeExceedsMaximum + } + let channelSizeSat = amountSats + lspBalance + if let maxChannelSizeSat = info?.options.maxChannelSizeSat, channelSizeSat > maxChannelSizeSat { + Logger.error("CJIT channel size exceeds maximum: \(channelSizeSat) > \(maxChannelSizeSat)") + throw CustomServiceError.channelSizeExceedsMaximum + } + return try await coreService.blocktank.createCjit( channelSizeSat: channelSizeSat, invoiceSat: amountSats, @@ -131,6 +140,47 @@ class BlocktankViewModel: ObservableObject { ) } + func canCreateCjit(amountSats: UInt64) async throws -> Bool { + if info == nil { + try await refreshInfo() + } + + guard let maxChannelSizeSat = info?.options.maxChannelSizeSat, maxChannelSizeSat > 0 else { + return true + } + + let lspBalance = try await getDefaultLspBalance(clientBalance: amountSats) + guard amountSats <= maxChannelSizeSat else { + return false + } + + return lspBalance <= maxChannelSizeSat - amountSats + } + + func maxCjitAmountSats() async throws -> UInt64? { + if info == nil { + try await refreshInfo() + } + + guard let maxChannelSizeSat = info?.options.maxChannelSizeSat, maxChannelSizeSat > 0 else { + return nil + } + + var lowerBound: UInt64 = 0 + var upperBound = maxChannelSizeSat + + while lowerBound < upperBound { + let candidate = lowerBound + (upperBound - lowerBound + 1) / 2 + if try await canCreateCjit(amountSats: candidate) { + lowerBound = candidate + } else { + upperBound = candidate - 1 + } + } + + return lowerBound + } + func createOrder(clientBalance: UInt64, lspBalance: UInt64? = nil) async throws -> IBtOrder { let finalReceivingBalanceSats = lspBalance ?? (clientBalance * 2) diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index 613498bfa..f37894e82 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -33,6 +33,7 @@ enum SheetID: String, CaseIterable { struct SheetConfiguration { let id: SheetID let data: Any? + let presentationID = UUID() } class SheetViewModel: ObservableObject { @@ -318,7 +319,7 @@ class SheetViewModel: ObservableObject { guard let config = activeSheetConfiguration, config.id == .receive else { return nil } let receiveConfig = config.data as? ReceiveConfig let initialRoute = receiveConfig?.initialRoute ?? .qr(cjitInvoice: nil, tab: nil) - return ReceiveSheetItem(initialRoute: initialRoute) + return ReceiveSheetItem(id: config.presentationID, initialRoute: initialRoute) } set { if newValue == nil { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 67f2a0693..b516a24f8 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -895,12 +895,6 @@ class TransferViewModel: ObservableObject { return (result, result) } - /// Calculates max client balance accounting for LDK reserve requirement - func getMaxClientBalance(maxChannelSize: UInt64) -> UInt64 { - let minRemoteBalance = UInt64(Double(maxChannelSize) * 0.025) - return maxChannelSize - minRemoteBalance - } - // MARK: - Manual Channel Opening /// Opens a manual channel and tracks the transfer diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index b4068245e..1efb42aef 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -1198,11 +1198,28 @@ class WalletViewModel: ObservableObject { return channels?.contains(where: \.isChannelReady) ?? false } + var hasExistingChannels: Bool { + channelCount > 0 || channels?.isEmpty == false + } + /// Returns true if there's at least one usable channel (ready AND peer connected) var hasUsableChannels: Bool { return channels?.contains(where: \.isUsable) ?? false } + var canCreateReceiveLightningInvoice: Bool { + let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil + return canCreateReceiveLightningInvoice(amountSats: amountSats) + } + + func canCreateReceiveLightningInvoice(amountSats: UInt64?) -> Bool { + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: hasReadyChannels, + inboundCapacitySats: totalInboundLightningSats, + invoiceAmountSats: amountSats + ) + } + @discardableResult private func refreshReusableOnchainAddress() async throws -> String { let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) @@ -1359,8 +1376,7 @@ class WalletViewModel: ObservableObject { let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil - // Create Lightning invoice if at least one channel is ready - if hasReadyChannels { + if canCreateReceiveLightningInvoice(amountSats: amountSats) { if forceRefreshBolt11 || bolt11.isEmpty { bolt11 = try await createInvoice(amountSats: amountSats, note: invoiceNote) } else { diff --git a/Bitkit/Views/Wallets/Receive/QrArea.swift b/Bitkit/Views/Wallets/Receive/QrArea.swift index 6d6c4e980..10021bcc6 100644 --- a/Bitkit/Views/Wallets/Receive/QrArea.swift +++ b/Bitkit/Views/Wallets/Receive/QrArea.swift @@ -5,6 +5,7 @@ struct QrArea: View { let uri: String let imageAsset: String? let accentColor: Color + let sourceTab: ReceiveQr.ReceiveTab @Binding var navigationPath: [ReceiveRoute] @State private var showCopyTooltip = false @@ -39,7 +40,7 @@ struct QrArea: View { icon: Image("pencil").foregroundColor(accentColor), shouldExpand: true ) { - navigationPath.append(.edit) + navigationPath.append(.edit(tab: sourceTab)) } .accessibilityIdentifier("SpecifyInvoiceButton") diff --git a/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift b/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift index bfa9ef31d..0fc7b4a31 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift @@ -10,6 +10,7 @@ struct ReceiveCjitAmount: View { @Binding var navigationPath: [ReceiveRoute] @State private var amountViewModel = AmountInputViewModel() + @State private var maxCjitAmount: UInt64? var minimumAmount: UInt64 { blocktank.minCjitSats ?? 0 @@ -78,10 +79,33 @@ struct ReceiveCjitAmount: View { .sheetBackground() .task { try? await blocktank.refreshMinCjitSats() + await refreshMaxCjitAmount() + updateInputCap() + } + .onChange(of: blocktank.info?.options.maxChannelSizeSat) { + Task { + await refreshMaxCjitAmount() + } + } + .onChange(of: maxCjitAmount) { + updateInputCap() + } + .onChange(of: amountViewModel.maxExceededCount) { + showMaxExceededToast() } } private func onContinue() async { + if maxCjitAmount == nil { + await refreshMaxCjitAmount() + updateInputCap() + } + + guard isWithinMaxCjitAmount else { + showMaxExceededToast() + return + } + // Wait until node is running if it's in starting state if await wallet.waitForNodeToRun() { // Only proceed if node is running @@ -89,6 +113,16 @@ struct ReceiveCjitAmount: View { let entry = try await blocktank.createCjit(amountSats: amountSats, description: "Bitkit") navigationPath.append(.cjitConfirm(entry: entry, receiveAmountSats: amountSats, isAdditional: false)) } catch { + if isMaxCjitAmountError(error) { + if maxCjitAmount == nil { + await refreshMaxCjitAmount() + updateInputCap() + } + showMaxExceededToast() + Logger.error(error) + return + } + app.toast(error) Logger.error(error) } @@ -97,4 +131,45 @@ struct ReceiveCjitAmount: View { app.toast(type: .warning, title: "Lightning not ready", description: "Lightning node must be running to create an invoice") } } + + private var isWithinMaxCjitAmount: Bool { + guard let maxCjitAmount, maxCjitAmount > 0 else { + return true + } + + return amountSats <= maxCjitAmount + } + + private func updateInputCap() { + amountViewModel.maxAmountOverride = (maxCjitAmount ?? 0) > 0 ? maxCjitAmount : nil + } + + private func refreshMaxCjitAmount() async { + do { + maxCjitAmount = try await blocktank.maxCjitAmountSats() + } catch { + Logger.error("Failed to calculate max CJIT amount: \(error)") + maxCjitAmount = nil + } + } + + private func showMaxExceededToast() { + app.toast( + type: .warning, + title: t("wallet__receive_cjit_error_max__title"), + description: t( + "wallet__receive_cjit_error_max__description", + variables: ["amount": CurrencyFormatter.formatSats(maxCjitAmount ?? 0)] + ), + accessibilityIdentifier: "ReceiveCjitAmountExceededToast" + ) + } + + private func isMaxCjitAmountError(_ error: Error) -> Bool { + let description = String(describing: error) + return description.contains("Channel size is too big") + || description.contains("channelSizeExceedsMaximum") + || description.contains("maxChannelSizeSat") + || description.contains("channelSizeSat") + } } diff --git a/Bitkit/Views/Wallets/Receive/ReceiveCjitConfirmation.swift b/Bitkit/Views/Wallets/Receive/ReceiveCjitConfirmation.swift index d9f4dfac2..c3a37863c 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveCjitConfirmation.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveCjitConfirmation.swift @@ -9,6 +9,11 @@ struct ReceiveCjitConfirmation: View { @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var wallet: WalletViewModel + + private var isAdditionalFlow: Bool { + isAdditional || wallet.hasExistingChannels + } private func formattedNetworkFee() -> String { guard let converted = currency.convert(sats: entry.networkFeeSat) else { @@ -38,7 +43,7 @@ struct ReceiveCjitConfirmation: View { BodyMText( t( - isAdditional ? "wallet__receive_connect_additional" : "wallet__receive_connect_initial", + isAdditionalFlow ? "wallet__receive_connect_additional" : "wallet__receive_connect_initial", variables: [ "networkFee": formattedNetworkFee(), "serviceFee": formattedServiceFee(), @@ -75,7 +80,7 @@ struct ReceiveCjitConfirmation: View { HStack(spacing: 16) { CustomButton(title: t("common__learn_more"), variant: .secondary) { - navigationPath.append(.cjitLearnMore(entry: entry, receiveAmountSats: receiveAmountSats, isAdditional: isAdditional)) + navigationPath.append(.cjitLearnMore(entry: entry, receiveAmountSats: receiveAmountSats, isAdditional: isAdditionalFlow)) } CustomButton(title: t("common__continue")) { diff --git a/Bitkit/Views/Wallets/Receive/ReceiveCjitLearnMore.swift b/Bitkit/Views/Wallets/Receive/ReceiveCjitLearnMore.swift index 1ff956851..17d99523e 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveCjitLearnMore.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveCjitLearnMore.swift @@ -8,22 +8,33 @@ struct ReceiveCjitLearnMore: View { @Environment(\.dismiss) var dismiss @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var wallet: WalletViewModel + + private var isAdditionalFlow: Bool { + isAdditional || wallet.hasExistingChannels + } + + var navTitle: String { + isAdditionalFlow + ? t("wallet__receive_liquidity__nav_title_additional") + : t("wallet__receive_liquidity__nav_title") + } var text: String { - isAdditional + isAdditionalFlow ? t("wallet__receive_liquidity__text_additional") : t("wallet__receive_liquidity__text") } var label: String { - isAdditional + isAdditionalFlow ? t("wallet__receive_liquidity__label_additional") : t("wallet__receive_liquidity__label") } var body: some View { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__receive_liquidity__nav_title"), showBackButton: true) + SheetHeader(title: navTitle, showBackButton: true) BodyMText(text) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index f8bf147b4..6e225f736 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -5,7 +5,6 @@ struct ReceiveEdit: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var blocktank: BlocktankViewModel @EnvironmentObject private var currency: CurrencyViewModel - @EnvironmentObject private var transfer: TransferViewModel @EnvironmentObject private var wallet: WalletViewModel @EnvironmentObject private var tagManager: TagManager @Environment(PaykitPaymentRequestManager.self) private var paymentRequests @@ -14,6 +13,7 @@ struct ReceiveEdit: View { @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false @Binding var navigationPath: [ReceiveRoute] + let sourceTab: ReceiveQr.ReceiveTab let onSendPaymentRequest: (PaykitPaymentRequestDraft) -> Void @State private var amountViewModel = AmountInputViewModel() @@ -25,6 +25,17 @@ struct ReceiveEdit: View { amountViewModel.amountSats } + private var liquiditySource: ReceiveLiquiditySource { + switch sourceTab { + case .savings: + return .savings + case .unified: + return .auto + case .spending: + return .spending + } + } + var body: some View { VStack(spacing: 0) { SheetHeader(title: t("wallet__receive_specify"), showBackButton: true) @@ -155,14 +166,25 @@ struct ReceiveEdit: View { do { wallet.invoiceAmountSats = amountSats wallet.invoiceNote = note - try await wallet.refreshBip21(forceRefreshBolt11: true) - // Check if CJIT flow should be shown - if needsAdditionalCjit() { + var maxCjitAmountSats: UInt64? + if needsCjitLimitsForAdditionalLiquidity() { + try? await blocktank.refreshMinCjitSats() + maxCjitAmountSats = try? await blocktank.maxCjitAmountSats() + } + + switch additionalLiquidityAction(maxCjitAmountSats: maxCjitAmountSats) { + case .none: + try await wallet.refreshBip21(forceRefreshBolt11: true) + dismiss() + case .chooseAmount: + try await wallet.refreshBip21(forceRefreshBolt11: true) + navigationPath.append(.cjitAmount) + case let .createCjit(amountSats): let entry = try await blocktank.createCjit(amountSats: amountSats, description: note) navigationPath.append(.cjitConfirm(entry: entry, receiveAmountSats: amountSats, isAdditional: true)) - } else { - dismiss() + case .geoBlocked: + navigationPath.append(.cjitGeoBlocked) } } catch { app.toast(error) @@ -193,32 +215,24 @@ struct ReceiveEdit: View { } } - private func needsAdditionalCjit() -> Bool { - let isGeoBlocked = GeoService.shared.isGeoBlocked - let minimumAmount = blocktank.minCjitSats ?? 0 - let inboundCapacity = wallet.totalInboundLightningSats ?? 0 - let invoiceAmount = amountViewModel.amountSats - - // Calculate maxClientBalance using TransferViewModel - let maxChannelSize = blocktank.info?.options.maxChannelSizeSat ?? 0 - let maxClientBalance = transfer.getMaxClientBalance(maxChannelSize: UInt64(maxChannelSize)) - - if - // user is geo-blocked - isGeoBlocked || - // failed to get minimum amount - minimumAmount == 0 || - // amount is less than minimum CJIT amount - invoiceAmount < minimumAmount || - // there is enough inbound capacity - invoiceAmount <= inboundCapacity || - // amount is above the maximum client balance - invoiceAmount > maxClientBalance - { - return false - } + private func additionalLiquidityAction(maxCjitAmountSats: UInt64?) -> ReceiveAdditionalLiquidityAction { + ReceiveLiquidityDecision.additionalLiquidityAction( + source: liquiditySource, + invoiceAmountSats: amountViewModel.amountSats, + inboundCapacitySats: wallet.totalInboundLightningSats, + minCjitSats: blocktank.minCjitSats, + maxCjitAmountSats: maxCjitAmountSats, + isGeoBlocked: GeoService.shared.isGeoBlocked + ) + } - return true + private func needsCjitLimitsForAdditionalLiquidity() -> Bool { + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source: liquiditySource, + invoiceAmountSats: amountViewModel.amountSats, + inboundCapacitySats: wallet.totalInboundLightningSats, + isGeoBlocked: GeoService.shared.isGeoBlocked + ) } @ViewBuilder diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 236b1f712..a28aaa7c4 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -11,6 +11,7 @@ struct ReceiveQr: View { @State private var selectedTab: ReceiveTab @State private var showDetails = false @State private var hasAppliedDefaultTab = false + @State private var hasUserSelectedTab = false init( navigationPath: Binding<[ReceiveRoute]>, @@ -29,6 +30,7 @@ struct ReceiveQr: View { .savings } _selectedTab = State(initialValue: defaultTab) + _hasAppliedDefaultTab = State(initialValue: tab != nil) } enum ReceiveTab: CaseIterable, CustomStringConvertible { @@ -62,8 +64,19 @@ struct ReceiveQr: View { } } + private var selectedTabBinding: Binding { + Binding( + get: { selectedTab }, + set: { newTab in + selectedTab = newTab + hasUserSelectedTab = true + hasAppliedDefaultTab = true + } + ) + } + var showingCjitOnboarding: Bool { - return !wallet.hasReadyChannels && cjitInvoice == nil && selectedTab == .spending + return !wallet.canCreateReceiveLightningInvoice && cjitInvoice == nil && selectedTab == .spending } var body: some View { @@ -72,7 +85,7 @@ struct ReceiveQr: View { .padding(.horizontal, 16) .padding(.bottom, UIScreen.main.isSmall ? -16 : 0) - SegmentedControl(selectedTab: $selectedTab, tabItems: availableTabItems) + SegmentedControl(selectedTab: selectedTabBinding, tabItems: availableTabItems) .padding(.bottom, 16) .padding(.horizontal, 16) @@ -101,10 +114,10 @@ struct ReceiveQr: View { .foregroundColor(.purpleAccent), isDisabled: wallet.nodeLifecycleState != .running ) { - if !wallet.hasReadyChannels && !GeoService.shared.isGeoBlocked { - navigationPath.append(.cjitAmount) - } else if GeoService.shared.isGeoBlocked { + if GeoService.shared.isGeoBlocked { navigationPath.append(.cjitGeoBlocked) + } else if !wallet.canCreateReceiveLightningInvoice { + navigationPath.append(.cjitAmount) } } } else if showDetails { @@ -119,7 +132,7 @@ struct ReceiveQr: View { } .accessibilityIdentifier("QRCode") } else { - CustomButton(title: t("common__show_details"), variant: .tertiary) { + CustomButton(title: t("common__show_details")) { showDetails.toggle() } .accessibilityIdentifier("ShowDetails") @@ -128,16 +141,7 @@ struct ReceiveQr: View { .padding(.horizontal, 16) } .onAppear { - // Apply the default-tab choice at most once, on the first appearance. The flag is set - // unconditionally here (even before bolt11 is ready) so a later reappearance — e.g. - // returning from Edit once the invoice has loaded — can never override the tab the user picked. - if !hasAppliedDefaultTab { - hasAppliedDefaultTab = true - // Default to the unified ("Auto") tab when a Lightning invoice is already available. - if tab == nil && !wallet.bolt11.isEmpty { - selectedTab = .unified - } - } + applyDefaultTabIfNeeded() } } .navigationBarHidden(true) @@ -164,11 +168,27 @@ struct ReceiveQr: View { } } } + .onChange(of: wallet.bolt11) { _, bolt11 in + if bolt11.isEmpty && selectedTab == .unified { + selectedTab = .savings + } + + applyDefaultTabIfNeeded() + } + } + + private func applyDefaultTabIfNeeded() { + guard tab == nil, !hasAppliedDefaultTab, !hasUserSelectedTab, !wallet.bolt11.isEmpty else { + return + } + + selectedTab = .unified + hasAppliedDefaultTab = true } func tabContent(for tab: ReceiveTab) -> some View { VStack(spacing: 0) { - if tab == .spending && wallet.channelCount == 0 && cjitInvoice == nil { + if tab == .spending && !wallet.canCreateReceiveLightningInvoice && cjitInvoice == nil { cjitOnboarding } else if showDetails { detailsContent(for: tab) @@ -187,7 +207,7 @@ struct ReceiveQr: View { let config = qrConfig(for: tab) if !config.uri.isEmpty { - QrArea(uri: config.uri, imageAsset: config.imageAsset, accentColor: config.accentColor, navigationPath: $navigationPath) + QrArea(uri: config.uri, imageAsset: config.imageAsset, accentColor: config.accentColor, sourceTab: tab, navigationPath: $navigationPath) } else { ProgressView() } @@ -315,7 +335,7 @@ struct ReceiveQr: View { }() if !addressPairs.isEmpty { - CopyAddressCard(addresses: addressPairs, navigationPath: $navigationPath) + CopyAddressCard(addresses: addressPairs, sourceTab: tab, navigationPath: $navigationPath) } Spacer() diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift index c917fb3e7..fd600c2b4 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift @@ -3,7 +3,7 @@ import SwiftUI enum ReceiveRoute: Hashable { case qr(cjitInvoice: String?, tab: ReceiveQr.ReceiveTab?) - case edit + case edit(tab: ReceiveQr.ReceiveTab) case tag case cjitAmount case cjitConfirm(entry: IcJitEntry, receiveAmountSats: UInt64, isAdditional: Bool) @@ -23,11 +23,12 @@ struct ReceiveConfig { } struct ReceiveSheetItem: SheetItem { - let id: SheetID = .receive + let id: UUID let size: SheetSize = .large let initialRoute: ReceiveRoute - init(initialRoute: ReceiveRoute = .qr(cjitInvoice: nil, tab: nil)) { + init(id: UUID = UUID(), initialRoute: ReceiveRoute = .qr(cjitInvoice: nil, tab: nil)) { + self.id = id self.initialRoute = initialRoute } } @@ -48,9 +49,11 @@ struct ReceiveSheet: View { viewForRoute(route) } } + .id(config.id) } .offlineSheetOverlay(title: t("wallet__receive_bitcoin")) .onAppear { + navigationPath = [] wallet.invoiceAmountSats = 0 wallet.invoiceNote = "" tagManager.clearSelectedTags() @@ -73,8 +76,8 @@ struct ReceiveSheet: View { cjitInvoice: cjitInvoice, tab: tab ) - case .edit: - ReceiveEdit(navigationPath: $navigationPath) { draft in + case let .edit(tab): + ReceiveEdit(navigationPath: $navigationPath, sourceTab: tab) { draft in navigationPath.append(.paymentRequestRecipient(draft)) } case .tag: diff --git a/BitkitTests/ReceiveLiquidityDecisionTests.swift b/BitkitTests/ReceiveLiquidityDecisionTests.swift new file mode 100644 index 000000000..06ddf67fa --- /dev/null +++ b/BitkitTests/ReceiveLiquidityDecisionTests.swift @@ -0,0 +1,202 @@ +@testable import Bitkit +import XCTest + +final class ReceiveLiquidityDecisionTests: XCTestCase { + func testLightningInvoiceRequiresReadyChannel() { + XCTAssertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: false, + inboundCapacitySats: 1000, + invoiceAmountSats: nil + ) + ) + } + + func testVariableLightningInvoiceRequiresNonZeroInboundLiquidity() { + XCTAssertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: true, + inboundCapacitySats: 0, + invoiceAmountSats: nil + ) + ) + + XCTAssertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: true, + inboundCapacitySats: 1, + invoiceAmountSats: nil + ) + ) + } + + func testFixedLightningInvoiceRequiresInboundLiquidityCoveringAmount() { + XCTAssertTrue( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: true, + inboundCapacitySats: 5000, + invoiceAmountSats: 5000 + ) + ) + + XCTAssertFalse( + ReceiveLiquidityDecision.canCreateLightningInvoice( + hasReadyChannels: true, + inboundCapacitySats: 4999, + invoiceAmountSats: 5000 + ) + ) + } + + func testZeroInboundDoesNotRouteToCjit() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 0, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: false + ), + .none + ) + } + + func testSavingsAndAutoEditsDoNotRouteToCjit() { + for source in [ReceiveLiquiditySource.savings, .auto] { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: source, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: false + ), + .none + ) + } + } + + func testBelowCjitMinimumRoutesToAmountPicker() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 4000, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: false + ), + .chooseAmount + ) + } + + func testAtCjitMinimumCreatesCjit() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 5000, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: false + ), + .createCjit(5000) + ) + } + + func testOverMaxCjitAmountRoutesToAmountPicker() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 100_001, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: false + ), + .chooseAmount + ) + } + + func testUnknownMaxCjitAmountRoutesToAmountPicker() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: nil, + isGeoBlocked: false + ), + .chooseAmount + ) + } + + func testGeoBlockedRoutesToGeoBlock() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: true + ), + .geoBlocked + ) + } + + func testNoAdditionalLiquidityNeededReturnsNone() { + XCTAssertEqual( + ReceiveLiquidityDecision.additionalLiquidityAction( + source: .spending, + invoiceAmountSats: 1000, + inboundCapacitySats: 1000, + minCjitSats: 5000, + maxCjitAmountSats: 100_000, + isGeoBlocked: false + ), + .none + ) + } + + func testCjitLimitsAreFetchedOnlyWhenAdditionalLiquidityCanUseThem() { + XCTAssertFalse( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source: .auto, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + isGeoBlocked: false + ) + ) + + XCTAssertFalse( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 0, + isGeoBlocked: false + ) + ) + + XCTAssertFalse( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + isGeoBlocked: true + ) + ) + + XCTAssertTrue( + ReceiveLiquidityDecision.needsCjitLimitsForAdditionalLiquidity( + source: .spending, + invoiceAmountSats: 10000, + inboundCapacitySats: 1000, + isGeoBlocked: false + ) + ) + } +} diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift new file mode 100644 index 000000000..b97b52a30 --- /dev/null +++ b/BitkitTests/ReceiveSheetSessionTests.swift @@ -0,0 +1,20 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class ReceiveSheetSessionTests: XCTestCase { + func testReceiveSheetItemGetsFreshIdentityPerPresentation() { + let sheets = SheetViewModel() + + sheets.showSheet(.receive) + let firstID = sheets.receiveSheetItem?.id + + sheets.hideSheet(reason: "test") + sheets.showSheet(.receive) + let secondID = sheets.receiveSheetItem?.id + + XCTAssertNotNil(firstID) + XCTAssertNotNil(secondID) + XCTAssertNotEqual(firstID, secondID) + } +} diff --git a/Docs/receive-liquidity.md b/Docs/receive-liquidity.md new file mode 100644 index 000000000..a66e5957b --- /dev/null +++ b/Docs/receive-liquidity.md @@ -0,0 +1,59 @@ +# Receive Liquidity Behavior + +This document describes how the receive flow decides whether to show a normal Lightning invoice or route the user into CJIT liquidity setup. + +## Cases + +- Opening the Receive sheet: + - A new Receive sheet session starts from a fresh tab state. + - If Auto is available, the default tab is Auto. + - If Auto is unavailable, the default tab is Savings. + - Temporary receive-session state, such as selected tab, nested navigation, pending CJIT details, and CJIT invoice QR state, must not survive closing and reopening the Receive sheet. + +- Editing from Savings or Auto: + - Editing sets the amount for the receive request. + - If the edited amount can be received over Lightning, the regenerated Spending invoice also includes that amount. + - If the edited amount cannot be received over Lightning, Auto falls back to the Savings tab and shows the onchain QR instead of routing to CJIT. + - The edit flow does not create CJIT or route to CJIT amount entry. + +- Lightning receive unavailable because there is no ready channel or inbound liquidity is `0`: + - No Lightning invoice is created. + - The normal QR remains Savings/onchain only. + - The Spending tab shows CJIT onboarding. + - Tapping receive spending routes to CJIT amount entry, or the CJIT geo-block screen when geo-blocked. + - Editing from Savings or Auto updates the receive amount and returns to the normal QR; it does not create or route to CJIT. + - When a channel already exists, later CJIT confirmation and learn-more screens use additional-liquidity copy. + +- Ready channel, inbound liquidity greater than `0`, zero/variable amount: + - A Lightning invoice is allowed. + - A zero/variable Lightning invoice is allowed when inbound liquidity is greater than `0`, even though the sender could later choose an amount above the available inbound capacity. + +- Ready channel, fixed amount less than or equal to inbound liquidity: + - A normal BOLT11 invoice is created. + - The unified QR includes Lightning. + - The Spending tab shows the normal Lightning invoice. + +- Ready channel, fixed amount greater than inbound liquidity but below CJIT minimum: + - A normal Lightning invoice is not shown. + - Editing from Spending routes to CJIT amount entry. + - The user must choose at least the minimum CJIT amount. + - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. + +- Ready channel, fixed amount greater than inbound liquidity and at or above CJIT minimum: + - If editing from Spending and the amount can be backed by a CJIT channel without exceeding Blocktank's maximum channel size, the edit flow creates additional CJIT. + - The user gets CJIT confirmation and then a CJIT Lightning invoice QR. + - The CJIT Lightning invoice is an invoice to the LSP and must be shown as Spending-only, not as Auto/unified receive. + - The direct additional CJIT path must not regenerate the normal receive invoice before creating CJIT. + - If editing from Spending and the amount is too large for CJIT, or the maximum cannot be calculated, the edit flow routes to CJIT amount entry. + - The CJIT amount screen enforces the real maximum receivable amount, calculated from `invoiceSat + defaultLspBalance(invoiceSat) <= maxChannelSizeSat`. + - Editing from Savings or Auto returns to the normal QR with Savings/onchain only. + +- Geo-blocked and liquidity is needed: + - The flow routes to the CJIT geo-block screen. + - No CJIT invoice is created. + +## Invariants + +- Auto tab availability and default tab selection are based on whether a normal Lightning invoice can be created for the current receive amount. +- Ready channels alone do not imply Auto availability; fixed receive amounts must also fit within inbound liquidity. +- CJIT min and max limits are only needed when a Spending-origin edit needs additional inbound liquidity and the user is not geo-blocked.