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.