-
Notifications
You must be signed in to change notification settings - Fork 4
fix(receive): handle additional receive liquidity edge cases #711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ canCreateCjit / maxCjitAmountSats have no unit coverage No iOS coverage for |
||
| 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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Filter inbound capacity to ready channels before gating invoice creation
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")) | ||
|
|
@@ -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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| } | ||
There was a problem hiding this comment.
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,canCreateCjitandmaxCjitAmountSatsonly callrefreshInfo()wheninfo == nil. Android'sfreshMaxChannelSizeSat()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 readingmaxChannelSizeSatso the two platforms enforce the same limit.