From 248339c6f5ead8afab9a105bc5392874cec3a684 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 09:54:30 -0300 Subject: [PATCH 01/23] fix: add expiry check for BIP 21 invoices --- Bitkit/ViewModels/AppViewModel.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index a3b878af..3bb124a2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -177,14 +177,18 @@ extension AppViewModel { } // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { - if lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { + if lightningInvoice.isExpired { + toast(type: .info, title: t("other__scan__error__expired"), description: nil) + // Fall through to on-chain handling below + } else if lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } + // If expired or insufficient funds -> proceed with on-chain } } - // No LN invoice found, proceed with onchain payment + // No LN invoice found or LN not usable, proceed with onchain payment handleScannedOnchainInvoice(invoice) case let .lightning(invoice): guard lightningService.status?.isRunning == true else { From 10fb65035cee01b23e0cedbd603ba20988003395 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 09:55:32 -0300 Subject: [PATCH 02/23] fix: add expiry check for pure lightning invoices --- Bitkit/ViewModels/AppViewModel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 3bb124a2..9a41af13 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -197,6 +197,13 @@ extension AppViewModel { } Logger.debug("Lightning: \(invoice)") + + // Check if Lightning invoice is expired + if invoice.isExpired { + toast(type: .error, title: t("other__scan__error__expired"), description: nil) + return + } + if lightningService.canSend(amountSats: invoice.amountSatoshis) { handleScannedLightningInvoice(invoice, bolt11: uri) } else { From 2c1ea1cdfbf4dddeeb1a4cc607c2aadbacc83ca6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 09:57:04 -0300 Subject: [PATCH 03/23] fix: check if invoice expired while user was on confirmation screen --- Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 10fcabe0..1108b4af 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -290,6 +290,11 @@ struct SendConfirmationView: View { var createdMetadataPaymentId: String? = nil do { + if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice, invoice.isExpired { + app.toast(type: .error, title: t("other__scan__error__expired"), description: nil) + return + } + if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis // Set the amount for the success screen From 886a4659aa23f7edf679f662f298e2aa1d2088e5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 10:17:22 -0300 Subject: [PATCH 04/23] fix: replace info type with error --- Bitkit/ViewModels/AppViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 9a41af13..82ae989d 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -178,7 +178,7 @@ extension AppViewModel { // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { if lightningInvoice.isExpired { - toast(type: .info, title: t("other__scan__error__expired"), description: nil) + toast(type: .error, title: t("other__scan__error__expired"), description: nil) // Fall through to on-chain handling below } else if lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) From 6cb9029d13281a7098b7259e1e82982245a38772 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 13:04:27 -0300 Subject: [PATCH 05/23] fix: handle route for null onchain invoice and expired ln bolt 11 --- Bitkit/Utilities/PaymentNavigationHelper.swift | 13 +++++++++---- Bitkit/ViewModels/AppViewModel.swift | 8 +++++++- .../Views/Wallets/Send/SendEnterManuallyView.swift | 7 ++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index acc87dea..55fbf2e1 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -102,7 +102,7 @@ struct PaymentNavigationHelper { app: AppViewModel, currency: CurrencyViewModel, settings: SettingsViewModel - ) -> SendRoute { + ) -> SendRoute? { if let lnurlWithdrawData = app.lnurlWithdrawData { if lnurlWithdrawData.minWithdrawable == lnurlWithdrawData.maxWithdrawable { return .lnurlWithdrawConfirm @@ -125,8 +125,8 @@ struct PaymentNavigationHelper { } // Handle lightning invoice - if app.scannedLightningInvoice != nil { - let amount = app.scannedLightningInvoice!.amountSatoshis + if let invoice = app.scannedLightningInvoice { + let amount = invoice.amountSatoshis if amount > 0 && shouldUseQuickpay { return .quickpay @@ -140,6 +140,11 @@ struct PaymentNavigationHelper { } // Handle onchain invoice - return .amount + if app.scannedOnchainInvoice != nil { + return .amount + } + + // No valid invoice data + return nil } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 82ae989d..e1654ca4 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -177,7 +177,13 @@ extension AppViewModel { } // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { + // Check if Lightning invoice is expired - if so, fallback to on-chain if available if lightningInvoice.isExpired { + // Only fallback to on-chain if address is available, otherwise return + guard !invoice.address.isEmpty else { + toast(type: .error, title: t("other__scan__error__expired"), description: nil) + return + } toast(type: .error, title: t("other__scan__error__expired"), description: nil) // Fall through to on-chain handling below } else if lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { @@ -199,7 +205,7 @@ extension AppViewModel { Logger.debug("Lightning: \(invoice)") // Check if Lightning invoice is expired - if invoice.isExpired { + guard !invoice.isExpired else { toast(type: .error, title: t("other__scan__error__expired"), description: nil) return } diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index 58d845ee..33176f85 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -72,12 +72,13 @@ struct SendEnterManuallyView: View { do { try await app.handleScannedData(uri) - let route = PaymentNavigationHelper.appropriateSendRoute( + if let route = PaymentNavigationHelper.appropriateSendRoute( app: app, currency: currency, settings: settings - ) - navigationPath.append(route) + ) { + navigationPath.append(route) + } } catch { Logger.error(error, context: "Failed to read data from clipboard") app.toast(error) From 8beb29a132a9feb5a35c5213a12e68c48f1446d3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 13:26:07 -0300 Subject: [PATCH 06/23] refactor: simplify logic --- Bitkit/ViewModels/AppViewModel.swift | 29 +++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e1654ca4..653d6e80 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -177,24 +177,19 @@ extension AppViewModel { } // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { - // Check if Lightning invoice is expired - if so, fallback to on-chain if available - if lightningInvoice.isExpired { - // Only fallback to on-chain if address is available, otherwise return - guard !invoice.address.isEmpty else { - toast(type: .error, title: t("other__scan__error__expired"), description: nil) - return - } - toast(type: .error, title: t("other__scan__error__expired"), description: nil) - // Fall through to on-chain handling below - } else if lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { + if !lightningInvoice.isExpired, lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } - // If expired or insufficient funds -> proceed with on-chain + // Lightning not usable (expired or insufficient funds) - fallback to on-chain if available + if lightningInvoice.isExpired { + toast(type: .error, title: t("other__scan__error__expired"), description: nil) + } } } - // No LN invoice found or LN not usable, proceed with onchain payment + // Fallback to on-chain if address is available + guard !invoice.address.isEmpty else { return } handleScannedOnchainInvoice(invoice) case let .lightning(invoice): guard lightningService.status?.isRunning == true else { @@ -202,19 +197,17 @@ extension AppViewModel { return } - Logger.debug("Lightning: \(invoice)") - - // Check if Lightning invoice is expired guard !invoice.isExpired else { toast(type: .error, title: t("other__scan__error__expired"), description: nil) return } - if lightningService.canSend(amountSats: invoice.amountSatoshis) { - handleScannedLightningInvoice(invoice, bolt11: uri) - } else { + guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") + return } + + handleScannedLightningInvoice(invoice, bolt11: uri) case let .lnurlPay(data: lnurlPayData): Logger.debug("LNURL: \(lnurlPayData)") handleLnurlPayInvoice(lnurlPayData) From b3b22a4969be81c23dd56ef6ac3a4f8a44dca130 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 12 Jan 2026 13:28:59 -0300 Subject: [PATCH 07/23] refactor: replace nil check with let --- Bitkit/Utilities/PaymentNavigationHelper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 55fbf2e1..70737565 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -140,7 +140,7 @@ struct PaymentNavigationHelper { } // Handle onchain invoice - if app.scannedOnchainInvoice != nil { + if let _ = app.scannedOnchainInvoice { return .amount } From 4a563fc42254268bc13e178614b7d7b122f450ad Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 07:09:27 -0300 Subject: [PATCH 08/23] fix: prioritize insufficient funds error --- Bitkit/ViewModels/AppViewModel.swift | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 653d6e80..4cf31de2 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -177,14 +177,19 @@ extension AppViewModel { } // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { - if !lightningInvoice.isExpired, lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) { - handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) - return + guard lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) else { + toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") + break // Fall through to on-chain } - // Lightning not usable (expired or insufficient funds) - fallback to on-chain if available - if lightningInvoice.isExpired { - toast(type: .error, title: t("other__scan__error__expired"), description: nil) + + // Then check expiry + guard !lightningInvoice.isExpired else { + toast(type: invoice.address.isEmpty ? .error : .info, title: t("other__scan__error__expired"), description: nil) + break // Fall through to on-chain } + + handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) + return } } @@ -197,13 +202,13 @@ extension AppViewModel { return } - guard !invoice.isExpired else { - toast(type: .error, title: t("other__scan__error__expired"), description: nil) + guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { + toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") return } - guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { - toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") + guard !invoice.isExpired else { + toast(type: .error, title: t("other__scan__error__expired"), description: nil) return } From 23b009405ec33877f11f57002d9b6374802b7c60 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 07:37:14 -0300 Subject: [PATCH 09/23] fix: prioritize insufficient funds error --- Bitkit/ViewModels/AppViewModel.swift | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 273c0d2b..c31ba493 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -177,19 +177,20 @@ extension AppViewModel { } // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { - guard lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) else { - toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") - break // Fall through to on-chain + let canSend = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) + + if !lightningInvoice.isExpired, canSend { + handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) + return } - // Then check expiry - guard !lightningInvoice.isExpired else { - toast(type: invoice.address.isEmpty ? .error : .info, title: t("other__scan__error__expired"), description: nil) - break // Fall through to on-chain + if !canSend { + toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") } - handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) - return + if lightningInvoice.isExpired, canSend { + toast(type: .error, title: t("other__scan__error__expired"), description: nil) + } } } @@ -579,6 +580,7 @@ extension AppViewModel { SettingsViewModel.shared.updatePinEnabledState() MigrationsService.shared.isShowingMigrationLoading = false + self.toast(type: .success, title: "Migration Complete", description: "Your wallet has been successfully migrated") } } else if MigrationsService.shared.isRestoringFromRNRemoteBackup { Task { From 21777f7b2e81a3919ea3106e670e482bc15ac141 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 07:44:56 -0300 Subject: [PATCH 10/23] fix: lightning only error message --- Bitkit/ViewModels/AppViewModel.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index c31ba493..2206ac7b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -184,10 +184,6 @@ extension AppViewModel { return } - if !canSend { - toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") - } - if lightningInvoice.isExpired, canSend { toast(type: .error, title: t("other__scan__error__expired"), description: nil) } @@ -209,7 +205,7 @@ extension AppViewModel { } guard !invoice.isExpired else { - toast(type: .error, title: t("other__scan__error__expired"), description: nil) + toast(type: .error, title: t("Decoding Error"), description: "This invoice has expired") return } From a880401dc55a976d36d7d2eb03c27f07eebff380 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 09:58:28 -0300 Subject: [PATCH 11/23] fix: add specific error cases and debounce --- Bitkit/ViewModels/AppViewModel.swift | 141 +++++++++++++++++- .../Wallets/Send/SendConfirmationView.swift | 6 +- .../Wallets/Send/SendEnterManuallyView.swift | 8 +- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 2206ac7b..f527b051 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1,7 +1,17 @@ import BitkitCore +import Combine import LDKNode import SwiftUI +enum ManualEntryValidationResult: Equatable { + case valid + case empty + case invalid + case insufficientSavings + case insufficientSpending + case expiredLightningOnly +} + @MainActor class AppViewModel: ObservableObject { // Send flow @@ -10,6 +20,7 @@ class AppViewModel: ObservableObject { @Published var selectedWalletToPayFrom: WalletType = .onchain @Published var manualEntryInput: String = "" @Published var isManualEntryInputValid: Bool = false + @Published var manualEntryValidationResult: ManualEntryValidationResult = .empty // LNURL @Published var lnurlPayData: LnurlPayData? @@ -63,6 +74,10 @@ class AppViewModel: ObservableObject { private let navigationViewModel: NavigationViewModel private var manualEntryValidationSequence: UInt64 = 0 + // Combine infrastructure for debounced validation + private var manualEntryValidationCancellable: AnyCancellable? + private let manualEntryValidationSubject = PassthroughSubject<(String, Int, Int), Never>() + init( lightningService: LightningService = .shared, coreService: CoreService = .shared, @@ -77,6 +92,8 @@ class AppViewModel: ObservableObject { // Start app status initialization timer startAppStatusInitializationTimer() + setupManualEntryValidationDebounce() + Task { await checkGeoStatus() // Check for app updates on startup @@ -84,6 +101,48 @@ class AppViewModel: ObservableObject { } } + private func setupManualEntryValidationDebounce() { + manualEntryValidationCancellable = manualEntryValidationSubject + .debounce(for: .milliseconds(1000), scheduler: DispatchQueue.main) + .sink { [weak self] rawValue, savingsBalanceSats, spendingBalanceSats in + guard let self else { return } + Task { + await self.performValidation(rawValue, savingsBalanceSats: savingsBalanceSats, spendingBalanceSats: spendingBalanceSats) + } + } + } + + private func showValidationErrorToast(for result: ManualEntryValidationResult) { + switch result { + case .invalid: + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic") + ) + case .insufficientSavings: + toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t("other__pay_insufficient_savings_description") + ) + case .insufficientSpending: + toast( + type: .error, + title: t("other__pay_insufficient_spending"), + description: t("other__pay_insufficient_savings_description") + ) + case .expiredLightningOnly: + toast( + type: .error, + title: t("other__scan__error__expired"), + description: nil + ) + case .valid, .empty: + break + } + } + // Convenience initializer for previews and testing convenience init() { self.init(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) @@ -370,23 +429,99 @@ extension AppViewModel { manualEntryValidationSequence &+= 1 manualEntryInput = "" isManualEntryInputValid = false + manualEntryValidationResult = .empty + } + + /// Queue validation with debounce + func validateManualEntryInput(_ rawValue: String, savingsBalanceSats: Int, spendingBalanceSats: Int) { + let normalized = normalizeManualEntry(rawValue) + + // Immediately update state for empty input (no debounce needed) + guard !normalized.isEmpty else { + manualEntryValidationResult = .empty + isManualEntryInputValid = false + return + } + + // Queue the validation with debounce + manualEntryValidationSubject.send((rawValue, savingsBalanceSats, spendingBalanceSats)) } - func validateManualEntryInput(_ rawValue: String) async { + /// Perform the actual validation + private func performValidation(_ rawValue: String, savingsBalanceSats: Int, spendingBalanceSats: Int) async { manualEntryValidationSequence &+= 1 let currentSequence = manualEntryValidationSequence let normalized = normalizeManualEntry(rawValue) guard !normalized.isEmpty else { + manualEntryValidationResult = .empty + isManualEntryInputValid = false + return + } + + // Try to decode the invoice + guard let decodedData = try? await decode(invoice: normalized) else { + guard currentSequence == manualEntryValidationSequence else { return } + manualEntryValidationResult = .invalid isManualEntryInputValid = false + showValidationErrorToast(for: .invalid) return } - let isValid = await (try? decode(invoice: normalized)) != nil + guard currentSequence == manualEntryValidationSequence else { return } + + // Determine validation result based on invoice type and balance + var result: ManualEntryValidationResult = .valid + + switch decodedData { + case let .lightning(invoice): + // Lightning-only invoice: check spending balance and expiry + let amountSats = invoice.amountSatoshis + + // Priority 1: Insufficient spending balance (only check if amount > 0) + if amountSats > 0 && spendingBalanceSats < Int(amountSats) { + result = .insufficientSpending + } else if invoice.isExpired { + // Priority 2: Expired invoice (only after balance check passes) + result = .expiredLightningOnly + } + + case let .onChain(invoice): + // BIP21 with potential lightning parameter + var canPayLightning = false + if let lnInvoice = invoice.params?["lightning"], + case let .lightning(lightningInvoice) = try? await decode(invoice: lnInvoice) + { + // Has lightning fallback - check if lightning is viable + canPayLightning = !lightningInvoice.isExpired && + (lightningInvoice.amountSatoshis == 0 || spendingBalanceSats >= Int(lightningInvoice.amountSatoshis)) + } + + if !canPayLightning { + // On-chain: check savings balance (only if amount specified) + if invoice.amountSatoshis > 0 && savingsBalanceSats < Int(invoice.amountSatoshis) { + result = .insufficientSavings + } + } + + case .lnurlPay, .lnurlWithdraw, .lnurlChannel, .lnurlAuth, .nodeId, .gift: + // These types are valid if decoded successfully + result = .valid + + default: + result = .invalid + } guard currentSequence == manualEntryValidationSequence else { return } - isManualEntryInputValid = isValid + + manualEntryValidationResult = result + isManualEntryInputValid = (result == .valid) + + // Show toast for error results + if result != .valid && result != .empty { + showValidationErrorToast(for: result) + } } } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 23c60f3f..3b674cc5 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -528,7 +528,11 @@ struct SendConfirmationView: View { private func navigateToManual(with value: String) { guard !value.isEmpty else { return } app.manualEntryInput = value - Task { await app.validateManualEntryInput(value) } + app.validateManualEntryInput( + value, + savingsBalanceSats: wallet.spendableOnchainBalanceSats, + spendingBalanceSats: wallet.maxSendLightningSats + ) if let manualIndex = navigationPath.firstIndex(of: .manual) { navigationPath = Array(navigationPath.prefix(manualIndex + 1)) diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index 33176f85..2c4f1762 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SendEnterManuallyView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @Binding var navigationPath: [SendRoute] @@ -12,7 +13,11 @@ struct SendEnterManuallyView: View { get: { app.manualEntryInput }, set: { newValue in app.manualEntryInput = newValue - Task { await app.validateManualEntryInput(newValue) } + app.validateManualEntryInput( + newValue, + savingsBalanceSats: wallet.spendableOnchainBalanceSats, + spendingBalanceSats: wallet.maxSendLightningSats + ) } ) } @@ -89,5 +94,6 @@ struct SendEnterManuallyView: View { #Preview { SendEnterManuallyView(navigationPath: .constant([])) .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) .preferredColorScheme(.dark) } From ea643323b77eee752fb87e0c52490f70ef497336 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 10:27:52 -0300 Subject: [PATCH 12/23] fix: stale input validation --- Bitkit/ViewModels/AppViewModel.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index f527b051..6570cd2d 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -76,7 +76,7 @@ class AppViewModel: ObservableObject { // Combine infrastructure for debounced validation private var manualEntryValidationCancellable: AnyCancellable? - private let manualEntryValidationSubject = PassthroughSubject<(String, Int, Int), Never>() + private let manualEntryValidationSubject = PassthroughSubject<(String, Int, Int, UInt64), Never>() init( lightningService: LightningService = .shared, @@ -104,8 +104,10 @@ class AppViewModel: ObservableObject { private func setupManualEntryValidationDebounce() { manualEntryValidationCancellable = manualEntryValidationSubject .debounce(for: .milliseconds(1000), scheduler: DispatchQueue.main) - .sink { [weak self] rawValue, savingsBalanceSats, spendingBalanceSats in + .sink { [weak self] rawValue, savingsBalanceSats, spendingBalanceSats, queuedSequence in guard let self else { return } + // Skip if sequence changed (reset was called or new validation queued) + guard queuedSequence == manualEntryValidationSequence else { return } Task { await self.performValidation(rawValue, savingsBalanceSats: savingsBalanceSats, spendingBalanceSats: spendingBalanceSats) } @@ -434,6 +436,10 @@ extension AppViewModel { /// Queue validation with debounce func validateManualEntryInput(_ rawValue: String, savingsBalanceSats: Int, spendingBalanceSats: Int) { + // Increment sequence first so any pending debounced requests become stale + manualEntryValidationSequence &+= 1 + let currentSequence = manualEntryValidationSequence + let normalized = normalizeManualEntry(rawValue) // Immediately update state for empty input (no debounce needed) @@ -443,13 +449,12 @@ extension AppViewModel { return } - // Queue the validation with debounce - manualEntryValidationSubject.send((rawValue, savingsBalanceSats, spendingBalanceSats)) + // Queue the validation with debounce, including the sequence to detect stale requests + manualEntryValidationSubject.send((rawValue, savingsBalanceSats, spendingBalanceSats, currentSequence)) } /// Perform the actual validation private func performValidation(_ rawValue: String, savingsBalanceSats: Int, spendingBalanceSats: Int) async { - manualEntryValidationSequence &+= 1 let currentSequence = manualEntryValidationSequence let normalized = normalizeManualEntry(rawValue) From e3e269c34bae25d16eae671d9083c3800f403aef Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 10:28:24 -0300 Subject: [PATCH 13/23] fix: disable autocapitalization and autocorrect --- Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index 2c4f1762..74c23dc8 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -45,6 +45,8 @@ struct SendEnterManuallyView: View { .foregroundColor(.textPrimary) .accentColor(.brandAccent) .submitLabel(.done) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() .dismissKeyboardOnReturn(text: manualEntryBinding, isFocused: $isTextEditorFocused) .accessibilityValue(app.manualEntryInput) .accessibilityIdentifier("RecipientInput") From daa26e06ab8cf2f3aabfb8ecad2965de8af5ab37 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 10:58:26 -0300 Subject: [PATCH 14/23] fix: add network validation --- Bitkit/ViewModels/AppViewModel.swift | 108 ++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 4 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 6570cd2d..0b607896 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -231,6 +231,17 @@ extension AppViewModel { switch data { // BIP21 (Unified) invoice handling case let .onChain(invoice): + // Check network first - treat wrong network as decoding error + let addressNetwork = getAddressNetwork(invoice.address) + if isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic") + ) + return + } + if let lnInvoice = invoice.params?["lightning"] { guard lightningService.status?.isRunning == true else { toast(type: .error, title: "Lightning not running", description: "Please try again later.") @@ -238,14 +249,18 @@ extension AppViewModel { } // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { + // Check lightning invoice network + let lnNetwork = convertNetworkType(lightningInvoice.networkType) + let lnNetworkMatch = !isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) + let canSend = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) - if !lightningInvoice.isExpired, canSend { + if lnNetworkMatch, !lightningInvoice.isExpired, canSend { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } - if lightningInvoice.isExpired, canSend { + if lnNetworkMatch, lightningInvoice.isExpired, canSend { toast(type: .error, title: t("other__scan__error__expired"), description: nil) } } @@ -255,6 +270,18 @@ extension AppViewModel { guard !invoice.address.isEmpty else { return } handleScannedOnchainInvoice(invoice) case let .lightning(invoice): + // Check network first - treat wrong network as decoding error + if let invoiceNetwork = convertNetworkType(invoice.networkType), + isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) + { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic") + ) + return + } + guard lightningService.status?.isRunning == true else { toast(type: .error, title: "Lightning not running", description: "Please try again later.") return @@ -289,7 +316,17 @@ extension AppViewModel { return } - // TODO: add network check + // Check network - treat wrong network as decoding error + if let nodeNetwork = convertNetworkType(network), + isNetworkMismatch(addressNetwork: nodeNetwork, currentNetwork: Env.network) + { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic") + ) + return + } handleNodeUri(url, network) case let .gift(code, amount): @@ -481,6 +518,14 @@ extension AppViewModel { switch decodedData { case let .lightning(invoice): + // Priority 0: Check network first - treat wrong network as invalid + if let invoiceNetwork = convertNetworkType(invoice.networkType), + isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) + { + result = .invalid + break + } + // Lightning-only invoice: check spending balance and expiry let amountSats = invoice.amountSatoshis @@ -493,13 +538,24 @@ extension AppViewModel { } case let .onChain(invoice): + // Priority 0: Check network first - treat wrong network as invalid + let addressNetwork = getAddressNetwork(invoice.address) + if isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { + result = .invalid + break + } + // BIP21 with potential lightning parameter var canPayLightning = false if let lnInvoice = invoice.params?["lightning"], case let .lightning(lightningInvoice) = try? await decode(invoice: lnInvoice) { + // Check lightning invoice network too + let lnNetwork = convertNetworkType(lightningInvoice.networkType) + let lnNetworkMatch = !isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) + // Has lightning fallback - check if lightning is viable - canPayLightning = !lightningInvoice.isExpired && + canPayLightning = lnNetworkMatch && !lightningInvoice.isExpired && (lightningInvoice.amountSatoshis == 0 || spendingBalanceSats >= Int(lightningInvoice.amountSatoshis)) } @@ -528,6 +584,50 @@ extension AppViewModel { showValidationErrorToast(for: result) } } + + /// Infer the Bitcoin network from an on-chain address prefix + private func getAddressNetwork(_ address: String) -> LDKNode.Network? { + let lowercased = address.lowercased() + + // Bech32/Bech32m addresses + if lowercased.hasPrefix("bc1") { + return .bitcoin + } else if lowercased.hasPrefix("bcrt1") { + return .regtest + } else if lowercased.hasPrefix("tb1") { + return .testnet + } + + // Legacy addresses - check first character + guard let first = address.first else { return nil } + switch first { + case "1", "3": return .bitcoin + case "m", "n", "2": return .testnet // testnet and regtest share these + default: return nil + } + } + + /// Convert BitkitCore.NetworkType to LDKNode.Network + private func convertNetworkType(_ networkType: NetworkType) -> LDKNode.Network? { + switch networkType { + case .bitcoin: return .bitcoin + case .testnet: return .testnet + case .signet: return .signet + case .regtest: return .regtest + } + } + + /// Check if an address network matches the current app network + private func isNetworkMismatch(addressNetwork: LDKNode.Network?, currentNetwork: LDKNode.Network) -> Bool { + guard let addressNetwork else { return false } + + // Special case: regtest uses testnet prefixes (m, n, 2, tb1) + if currentNetwork == .regtest && addressNetwork == .testnet { + return false + } + + return addressNetwork != currentNetwork + } } // MARK: LDK Node Events From e9812c867a624bada547522edb23369b0b4cd5fa Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 11:10:49 -0300 Subject: [PATCH 15/23] fix: lightning balance error --- Bitkit/ViewModels/AppViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 0b607896..c029236c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -288,7 +288,11 @@ extension AppViewModel { } guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { - toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") + toast( + type: .error, + title: t("other__pay_insufficient_spending"), + description: t("other__pay_insufficient_savings_description") + ) return } From 65627faa2dd7ddfe93a29dc6ca6e048b2992335e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 11:24:05 -0300 Subject: [PATCH 16/23] fix: onchain balance validation for pasting option --- Bitkit/ViewModels/AppViewModel.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index c029236c..886ae601 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -268,6 +268,20 @@ extension AppViewModel { // Fallback to on-chain if address is available guard !invoice.address.isEmpty else { return } + + // Check on-chain balance if amount is specified + if invoice.amountSatoshis > 0 { + let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + guard onchainBalance >= invoice.amountSatoshis else { + toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t("other__pay_insufficient_savings_description") + ) + return + } + } + handleScannedOnchainInvoice(invoice) case let .lightning(invoice): // Check network first - treat wrong network as decoding error From 9a054b1f3a2a5085d9637e6a4b56147985e0db3a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 11:43:26 -0300 Subject: [PATCH 17/23] refactor: extract network validation --- .../Utilities/NetworkValidationHelper.swift | 57 +++++++ Bitkit/ViewModels/AppViewModel.swift | 75 ++------- .../NetworkValidationHelperTests.swift | 143 ++++++++++++++++++ 3 files changed, 214 insertions(+), 61 deletions(-) create mode 100644 Bitkit/Utilities/NetworkValidationHelper.swift create mode 100644 BitkitTests/NetworkValidationHelperTests.swift diff --git a/Bitkit/Utilities/NetworkValidationHelper.swift b/Bitkit/Utilities/NetworkValidationHelper.swift new file mode 100644 index 00000000..32699401 --- /dev/null +++ b/Bitkit/Utilities/NetworkValidationHelper.swift @@ -0,0 +1,57 @@ +import BitkitCore +import LDKNode + +/// Helper for validating Bitcoin network compatibility of addresses and invoices +enum NetworkValidationHelper { + /// Infer the Bitcoin network from an on-chain address prefix + /// - Parameter address: The Bitcoin address to check + /// - Returns: The detected network, or nil if the address format is unrecognized + static func getAddressNetwork(_ address: String) -> LDKNode.Network? { + let lowercased = address.lowercased() + + // Bech32/Bech32m addresses (order matters: check bcrt1 before bc1) + if lowercased.hasPrefix("bcrt1") { + return .regtest + } else if lowercased.hasPrefix("bc1") { + return .bitcoin + } else if lowercased.hasPrefix("tb1") { + return .testnet + } + + // Legacy addresses - check first character + guard let first = address.first else { return nil } + switch first { + case "1", "3": return .bitcoin + case "m", "n", "2": return .testnet // testnet and regtest share these + default: return nil + } + } + + /// Convert BitkitCore.NetworkType to LDKNode.Network + /// - Parameter networkType: The BitkitCore network type + /// - Returns: The equivalent LDKNode network + static func convertNetworkType(_ networkType: NetworkType) -> LDKNode.Network { + switch networkType { + case .bitcoin: return .bitcoin + case .testnet: return .testnet + case .signet: return .signet + case .regtest: return .regtest + } + } + + /// Check if an address/invoice network mismatches the current app network + /// - Parameters: + /// - addressNetwork: The network detected from the address/invoice + /// - currentNetwork: The app's current network (typically Env.network) + /// - Returns: true if there's a mismatch (address won't work on current network) + static func isNetworkMismatch(addressNetwork: LDKNode.Network?, currentNetwork: LDKNode.Network) -> Bool { + guard let addressNetwork else { return false } + + // Special case: regtest uses testnet prefixes (m, n, 2, tb1) + if currentNetwork == .regtest && addressNetwork == .testnet { + return false + } + + return addressNetwork != currentNetwork + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 886ae601..2b8d1604 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -232,8 +232,8 @@ extension AppViewModel { // BIP21 (Unified) invoice handling case let .onChain(invoice): // Check network first - treat wrong network as decoding error - let addressNetwork = getAddressNetwork(invoice.address) - if isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { + let addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { toast( type: .error, title: t("other__scan_err_decoding"), @@ -250,8 +250,8 @@ extension AppViewModel { // Lightning invoice param found, prefer lightning payment if possible if case let .lightning(lightningInvoice) = try await decode(invoice: lnInvoice) { // Check lightning invoice network - let lnNetwork = convertNetworkType(lightningInvoice.networkType) - let lnNetworkMatch = !isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) + let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) + let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) let canSend = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) @@ -285,9 +285,8 @@ extension AppViewModel { handleScannedOnchainInvoice(invoice) case let .lightning(invoice): // Check network first - treat wrong network as decoding error - if let invoiceNetwork = convertNetworkType(invoice.networkType), - isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) - { + let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) { toast( type: .error, title: t("other__scan_err_decoding"), @@ -335,9 +334,8 @@ extension AppViewModel { } // Check network - treat wrong network as decoding error - if let nodeNetwork = convertNetworkType(network), - isNetworkMismatch(addressNetwork: nodeNetwork, currentNetwork: Env.network) - { + let nodeNetwork = NetworkValidationHelper.convertNetworkType(network) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: nodeNetwork, currentNetwork: Env.network) { toast( type: .error, title: t("other__scan_err_decoding"), @@ -537,9 +535,8 @@ extension AppViewModel { switch decodedData { case let .lightning(invoice): // Priority 0: Check network first - treat wrong network as invalid - if let invoiceNetwork = convertNetworkType(invoice.networkType), - isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) - { + let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) { result = .invalid break } @@ -557,8 +554,8 @@ extension AppViewModel { case let .onChain(invoice): // Priority 0: Check network first - treat wrong network as invalid - let addressNetwork = getAddressNetwork(invoice.address) - if isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { + let addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { result = .invalid break } @@ -569,8 +566,8 @@ extension AppViewModel { case let .lightning(lightningInvoice) = try? await decode(invoice: lnInvoice) { // Check lightning invoice network too - let lnNetwork = convertNetworkType(lightningInvoice.networkType) - let lnNetworkMatch = !isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) + let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) + let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) // Has lightning fallback - check if lightning is viable canPayLightning = lnNetworkMatch && !lightningInvoice.isExpired && @@ -602,50 +599,6 @@ extension AppViewModel { showValidationErrorToast(for: result) } } - - /// Infer the Bitcoin network from an on-chain address prefix - private func getAddressNetwork(_ address: String) -> LDKNode.Network? { - let lowercased = address.lowercased() - - // Bech32/Bech32m addresses - if lowercased.hasPrefix("bc1") { - return .bitcoin - } else if lowercased.hasPrefix("bcrt1") { - return .regtest - } else if lowercased.hasPrefix("tb1") { - return .testnet - } - - // Legacy addresses - check first character - guard let first = address.first else { return nil } - switch first { - case "1", "3": return .bitcoin - case "m", "n", "2": return .testnet // testnet and regtest share these - default: return nil - } - } - - /// Convert BitkitCore.NetworkType to LDKNode.Network - private func convertNetworkType(_ networkType: NetworkType) -> LDKNode.Network? { - switch networkType { - case .bitcoin: return .bitcoin - case .testnet: return .testnet - case .signet: return .signet - case .regtest: return .regtest - } - } - - /// Check if an address network matches the current app network - private func isNetworkMismatch(addressNetwork: LDKNode.Network?, currentNetwork: LDKNode.Network) -> Bool { - guard let addressNetwork else { return false } - - // Special case: regtest uses testnet prefixes (m, n, 2, tb1) - if currentNetwork == .regtest && addressNetwork == .testnet { - return false - } - - return addressNetwork != currentNetwork - } } // MARK: LDK Node Events diff --git a/BitkitTests/NetworkValidationHelperTests.swift b/BitkitTests/NetworkValidationHelperTests.swift new file mode 100644 index 00000000..0a78b00a --- /dev/null +++ b/BitkitTests/NetworkValidationHelperTests.swift @@ -0,0 +1,143 @@ +@testable import Bitkit +import BitkitCore +import LDKNode +import XCTest + +final class NetworkValidationHelperTests: XCTestCase { + // MARK: - getAddressNetwork Tests + + // Mainnet addresses + func testGetAddressNetwork_MainnetBech32() { + let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .bitcoin) + } + + func testGetAddressNetwork_MainnetBech32Uppercase() { + let address = "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .bitcoin) + } + + func testGetAddressNetwork_MainnetP2PKH() { + let address = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .bitcoin) + } + + func testGetAddressNetwork_MainnetP2SH() { + let address = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .bitcoin) + } + + // Testnet addresses + func testGetAddressNetwork_TestnetBech32() { + let address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .testnet) + } + + func testGetAddressNetwork_TestnetP2PKH_m() { + let address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .testnet) + } + + func testGetAddressNetwork_TestnetP2PKH_n() { + let address = "n3ZddxzLvAY9o7184TB4c6FJasAybsw4HZ" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .testnet) + } + + func testGetAddressNetwork_TestnetP2SH() { + let address = "2MzQwSSnBHWHqSAqtTVQ6v47XtaisrJa1Vc" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .testnet) + } + + // Regtest addresses + func testGetAddressNetwork_RegtestBech32() { + let address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x" + XCTAssertEqual(NetworkValidationHelper.getAddressNetwork(address), .regtest) + } + + // Edge cases + func testGetAddressNetwork_EmptyString() { + XCTAssertNil(NetworkValidationHelper.getAddressNetwork("")) + } + + func testGetAddressNetwork_InvalidAddress() { + XCTAssertNil(NetworkValidationHelper.getAddressNetwork("invalid")) + } + + func testGetAddressNetwork_RandomText() { + XCTAssertNil(NetworkValidationHelper.getAddressNetwork("test123")) + } + + // MARK: - convertNetworkType Tests + + func testConvertNetworkType_Bitcoin() { + XCTAssertEqual(NetworkValidationHelper.convertNetworkType(.bitcoin), .bitcoin) + } + + func testConvertNetworkType_Testnet() { + XCTAssertEqual(NetworkValidationHelper.convertNetworkType(.testnet), .testnet) + } + + func testConvertNetworkType_Signet() { + XCTAssertEqual(NetworkValidationHelper.convertNetworkType(.signet), .signet) + } + + func testConvertNetworkType_Regtest() { + XCTAssertEqual(NetworkValidationHelper.convertNetworkType(.regtest), .regtest) + } + + // MARK: - isNetworkMismatch Tests + + func testIsNetworkMismatch_SameNetwork() { + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .bitcoin, currentNetwork: .bitcoin)) + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .testnet, currentNetwork: .testnet)) + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .regtest, currentNetwork: .regtest)) + } + + func testIsNetworkMismatch_DifferentNetwork() { + XCTAssertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .bitcoin, currentNetwork: .testnet)) + XCTAssertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .bitcoin, currentNetwork: .regtest)) + XCTAssertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .testnet, currentNetwork: .bitcoin)) + } + + func testIsNetworkMismatch_RegtestAcceptsTestnetPrefixes() { + // Regtest should accept testnet prefixes (m, n, 2, tb1) + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .testnet, currentNetwork: .regtest)) + } + + func testIsNetworkMismatch_TestnetRejectsRegtestAddresses() { + // Testnet should NOT accept regtest-specific addresses (bcrt1) + XCTAssertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork: .regtest, currentNetwork: .testnet)) + } + + func testIsNetworkMismatch_NilAddressNetwork() { + // When address network is nil (unrecognized format), no mismatch + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: nil, currentNetwork: .bitcoin)) + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: nil, currentNetwork: .regtest)) + } + + // MARK: - Integration Tests (combining methods) + + func testMainnetAddressOnRegtest_ShouldMismatch() { + let address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + let addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + XCTAssertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: .regtest)) + } + + func testTestnetAddressOnRegtest_ShouldNotMismatch() { + let address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx" + let addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: .regtest)) + } + + func testRegtestAddressOnMainnet_ShouldMismatch() { + let address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x" + let addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + XCTAssertTrue(NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: .bitcoin)) + } + + func testLegacyTestnetAddressOnRegtest_ShouldNotMismatch() { + let address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" // m-prefix testnet + let addressNetwork = NetworkValidationHelper.getAddressNetwork(address) + XCTAssertFalse(NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: .regtest)) + } +} From b584a107596aaeb5698bc1d0032346767210a285 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 12:00:07 -0300 Subject: [PATCH 18/23] refactor: remove useless expired validation --- Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 3b674cc5..11ed8fce 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -211,11 +211,6 @@ struct SendConfirmationView: View { var createdMetadataPaymentId: String? = nil do { - if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice, invoice.isExpired { - app.toast(type: .error, title: t("other__scan__error__expired"), description: nil) - return - } - if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis // Set the amount for the success screen From f2d77bbe3c197aeca012af3601ffab122155e629 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 13:33:46 -0300 Subject: [PATCH 19/23] fix: string reference --- Bitkit/ViewModels/AppViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 2b8d1604..b3e4dc31 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -310,7 +310,7 @@ extension AppViewModel { } guard !invoice.isExpired else { - toast(type: .error, title: t("Decoding Error"), description: "This invoice has expired") + toast(type: .error, title: t("other__scan_err_decoding"), description: t("other__scan__error__expired")) return } From 4f45850647a7583684c4a6d7044b76148458611f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 13:36:07 -0300 Subject: [PATCH 20/23] fix: stale sequence check --- Bitkit/ViewModels/AppViewModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index b3e4dc31..0d487517 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -565,6 +565,9 @@ extension AppViewModel { if let lnInvoice = invoice.params?["lightning"], case let .lightning(lightningInvoice) = try? await decode(invoice: lnInvoice) { + // Check for stale request after async decode + guard currentSequence == manualEntryValidationSequence else { return } + // Check lightning invoice network too let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) From 57ee8284353f83c59a57b6ab8bdd128388ce7b7f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 14 Jan 2026 14:17:48 -0300 Subject: [PATCH 21/23] fix: error toast description --- Bitkit/ViewModels/AppViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 0d487517..7132c54a 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -137,8 +137,8 @@ class AppViewModel: ObservableObject { case .expiredLightningOnly: toast( type: .error, - title: t("other__scan__error__expired"), - description: nil + title: t("other__scan_err_decoding"), + description: t("other__scan__error__expired") ) case .valid, .empty: break @@ -261,7 +261,7 @@ extension AppViewModel { } if lnNetworkMatch, lightningInvoice.isExpired, canSend { - toast(type: .error, title: t("other__scan__error__expired"), description: nil) + toast(type: .error, title: t("other__scan_err_decoding"), description: t("other__scan__error__expired")) } } } From 977ecd2b677f75539693c430fcd07f298fd15b3b Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Wed, 14 Jan 2026 22:36:08 +0100 Subject: [PATCH 22/23] fix(scan): improve toasts --- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Utilities/CurrencyFormatter.swift | 8 +++++ Bitkit/ViewModels/AppViewModel.swift | 29 +++++++++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 78a63393..bcb2a61a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -366,6 +366,7 @@ "other__pay_insufficient_spending" = "Insufficient Spending Balance"; "other__pay_insufficient_savings_description" = "More ₿ needed to pay this Bitcoin invoice."; "other__pay_insufficient_savings_amount_description" = "₿ {amount} more needed to pay this Bitcoin invoice."; +"other__pay_insufficient_spending_description" = "More ₿ needed to pay this Lightning invoice."; "other__pay_insufficient_spending_amount_description" = "₿ {amount} more needed to pay this Lightning invoice."; "other__swipe" = "Swipe To Confirm"; "other__scan_err_decoding" = "Decoding Error"; diff --git a/Bitkit/Utilities/CurrencyFormatter.swift b/Bitkit/Utilities/CurrencyFormatter.swift index e72ea691..89010c27 100644 --- a/Bitkit/Utilities/CurrencyFormatter.swift +++ b/Bitkit/Utilities/CurrencyFormatter.swift @@ -9,4 +9,12 @@ enum CurrencyFormatter { return formatter.string(from: amount as NSDecimalNumber) ?? "" } + + /// Format satoshis with space as grouping separator (e.g., 10000 -> "10 000") + static func formatSats(_ sats: UInt64) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = " " + return formatter.string(from: NSNumber(value: sats)) ?? String(sats) + } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 7132c54a..3fe7b54b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -260,19 +260,31 @@ extension AppViewModel { return } - if lnNetworkMatch, lightningInvoice.isExpired, canSend { - toast(type: .error, title: t("other__scan_err_decoding"), description: t("other__scan__error__expired")) - } + // If Lightning is expired or insufficient, fall back to on-chain silently (no toast) } } // Fallback to on-chain if address is available guard !invoice.address.isEmpty else { return } - // Check on-chain balance if amount is specified + // Check on-chain balance + let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 if invoice.amountSatoshis > 0 { - let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 guard onchainBalance >= invoice.amountSatoshis else { + let amountNeeded = invoice.amountSatoshis - onchainBalance + toast( + type: .error, + title: t("other__pay_insufficient_savings"), + description: t( + "other__pay_insufficient_savings_amount_description", + variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)] + ) + ) + return + } + } else { + // Zero-amount invoice: user must have some balance to proceed + guard onchainBalance > 0 else { toast( type: .error, title: t("other__pay_insufficient_savings"), @@ -301,10 +313,15 @@ extension AppViewModel { } guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { + let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 + let amountNeeded = invoice.amountSatoshis > spendingBalance ? invoice.amountSatoshis - spendingBalance : 0 + let description = amountNeeded > 0 + ? t("other__pay_insufficient_spending_amount_description", variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)]) + : t("other__pay_insufficient_spending_description") toast( type: .error, title: t("other__pay_insufficient_spending"), - description: t("other__pay_insufficient_savings_description") + description: description ) return } From c4d30e19ff3e344134b2c859d931571896f333c7 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 16 Jan 2026 22:59:02 +0100 Subject: [PATCH 23/23] add toast ids --- Bitkit/ViewModels/AppViewModel.swift | 37 +++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 20775704..3ab014ae 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -120,25 +120,29 @@ class AppViewModel: ObservableObject { toast( type: .error, title: t("other__scan_err_decoding"), - description: t("other__scan__error__generic") + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" ) case .insufficientSavings: toast( type: .error, title: t("other__pay_insufficient_savings"), - description: t("other__pay_insufficient_savings_description") + description: t("other__pay_insufficient_savings_description"), + accessibilityIdentifier: "InsufficientSavingsToast" ) case .insufficientSpending: toast( type: .error, title: t("other__pay_insufficient_spending"), - description: t("other__pay_insufficient_savings_description") + description: t("other__pay_insufficient_savings_description"), + accessibilityIdentifier: "InsufficientSpendingToast" ) case .expiredLightningOnly: toast( type: .error, title: t("other__scan_err_decoding"), - description: t("other__scan__error__expired") + description: t("other__scan__error__expired"), + accessibilityIdentifier: "ExpiredLightningToast" ) case .valid, .empty: break @@ -237,7 +241,8 @@ extension AppViewModel { toast( type: .error, title: t("other__scan_err_decoding"), - description: t("other__scan__error__generic") + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" ) return } @@ -278,7 +283,8 @@ extension AppViewModel { description: t( "other__pay_insufficient_savings_amount_description", variables: ["amount": CurrencyFormatter.formatSats(amountNeeded)] - ) + ), + accessibilityIdentifier: "InsufficientSavingsToast" ) return } @@ -288,7 +294,8 @@ extension AppViewModel { toast( type: .error, title: t("other__pay_insufficient_savings"), - description: t("other__pay_insufficient_savings_description") + description: t("other__pay_insufficient_savings_description"), + accessibilityIdentifier: "InsufficientSavingsToast" ) return } @@ -302,7 +309,8 @@ extension AppViewModel { toast( type: .error, title: t("other__scan_err_decoding"), - description: t("other__scan__error__generic") + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" ) return } @@ -321,13 +329,19 @@ extension AppViewModel { toast( type: .error, title: t("other__pay_insufficient_spending"), - description: description + description: description, + accessibilityIdentifier: "InsufficientSpendingToast" ) return } guard !invoice.isExpired else { - toast(type: .error, title: t("other__scan_err_decoding"), description: t("other__scan__error__expired")) + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__expired"), + accessibilityIdentifier: "ExpiredLightningToast" + ) return } @@ -356,7 +370,8 @@ extension AppViewModel { toast( type: .error, title: t("other__scan_err_decoding"), - description: t("other__scan__error__generic") + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" ) return }