Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Bitkit/Components/CopyAddressCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
98 changes: 98 additions & 0 deletions Bitkit/Models/ReceiveLiquidityDecision.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 3 additions & 1 deletion Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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 <accent>Lightning</accent> 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";
Expand All @@ -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";
Expand Down
50 changes: 50 additions & 0 deletions Bitkit/ViewModels/BlocktankViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Max check uses cached info; Android refreshes before checking

createCjit, canCreateCjit and maxCjitAmountSats only call refreshInfo() when info == nil. Android's freshMaxChannelSizeSat() refreshes on every call and has a unit test (canCreateCjit refreshes max channel size before checking amount) pinning that. Refresh (best-effort, keep cached on failure) before reading maxChannelSizeSat so the two platforms enforce the same limit.

Logger.error("CJIT channel size exceeds maximum: \(channelSizeSat) > \(maxChannelSizeSat)")
throw CustomServiceError.channelSizeExceedsMaximum
}

return try await coreService.blocktank.createCjit(
channelSizeSat: channelSizeSat,
invoiceSat: amountSats,
Expand All @@ -131,6 +140,47 @@ class BlocktankViewModel: ObservableObject {
)
}

func canCreateCjit(amountSats: UInt64) async throws -> Bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ canCreateCjit / maxCjitAmountSats have no unit coverage

No iOS coverage for canCreateCjit or the maxCjitAmountSats binary search; Android pins the equivalent in BlocktankRepoTest (canCreateCjit refreshes max channel size before checking amount). Worth adding, but note BlocktankViewModel.init fires refreshInfo() and startPolling(), so a test has to inject a stub CurrencyService and suppress the init-time refresh before assigning info.

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)

Expand Down
3 changes: 2 additions & 1 deletion Bitkit/ViewModels/SheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum SheetID: String, CaseIterable {
struct SheetConfiguration {
let id: SheetID
let data: Any?
let presentationID = UUID()
}

class SheetViewModel: ObservableObject {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 0 additions & 6 deletions Bitkit/ViewModels/TransferViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions Bitkit/ViewModels/WalletViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Filter inbound capacity to ready channels before gating invoice creation

totalInboundLightningSats (line 1184, pre-existing) sums inboundCapacityMsat over every channel including pending (isChannelReady == false) ones. Android's equivalent filters: calculateRemoteBalance() -> filterOpen() -> filter { it.isChannelReady }. This PR makes that unfiltered sum newly load-bearing by routing it into canCreateReceiveLightningInvoice, which now gates whether a bolt11 is offered at all. A pending channel's capacity inflates the total, so the gate returns true for an amount the node cannot receive and a plain bolt11 is shown instead of routing to CJIT. Filter by isChannelReady in totalInboundLightningSats.

Regression test:

@testable import Bitkit
import LDKNode
import XCTest

@MainActor
final class ReceiveInboundLiquidityTests: XCTestCase {
    func testPendingChannelInboundDoesNotEnableLightningInvoice() {
        let wallet = WalletViewModel()
        wallet.channels = [
            .mock(isChannelReady: true, isUsable: true, inboundCapacityMsat: 0),
            .mock(isChannelReady: false, isUsable: false, inboundCapacityMsat: 100_000_000),
        ]

        XCTAssertFalse(wallet.canCreateReceiveLightningInvoice(amountSats: 50_000))
    }
}

invoiceAmountSats: amountSats
)
}

@discardableResult
private func refreshReusableOnchainAddress() async throws -> String {
let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType"))
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion Bitkit/Views/Wallets/Receive/QrArea.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,7 +40,7 @@ struct QrArea: View {
icon: Image("pencil").foregroundColor(accentColor),
shouldExpand: true
) {
navigationPath.append(.edit)
navigationPath.append(.edit(tab: sourceTab))
}
.accessibilityIdentifier("SpecifyInvoiceButton")

Expand Down
75 changes: 75 additions & 0 deletions Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,17 +79,50 @@ 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
do {
let entry = try await blocktank.createCjit(amountSats: amountSats, description: "Bitkit")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 No in-flight guard on Continue; double tap creates two CJIT entries

Pre-existing, but Android's ReceiveAmountScreen guards with isCreatingInvoice and this PR touches onContinue. Two taps before the first createCjit returns produce two LSP entries and two .cjitConfirm pushes. Add an isCreating state, disable the button and early-return in onContinue while set.

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)
}
Expand All @@ -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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Substring match on "channelSizeSat" misclassifies the LSP's too-small rejection as maximum exceeded

The last clause strictly subsumes the maxChannelSizeSat clause above it: contains("channelSizeSat") fires on any LSP error text that names that field, including a below-minimum rejection, which is then reported as "Receiving Capacity Maximum ... ₿ {max}" telling the user to enter less. Reachable: .task swallows a failed refreshMinCjitSats(), leaving minimumAmount == 0, so the Continue button's min guard is vacuous and any amount reaches createCjit. Match on the maximum wording only (e.g. "bigger than the maximum"), keep channelSizeExceedsMaximum for the local throw, and let everything else fall through to app.toast(error).

}
}
Loading
Loading