diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 9cc7cbe8..4dd388e4 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -370,6 +370,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/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/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index acc87dea..70737565 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 let _ = app.scannedOnchainInvoice { + return .amount + } + + // No valid invoice data + return nil } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 2929cae9..3ab014ae 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, UInt64), 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,54 @@ class AppViewModel: ObservableObject { } } + private func setupManualEntryValidationDebounce() { + manualEntryValidationCancellable = manualEntryValidationSubject + .debounce(for: .milliseconds(1000), scheduler: DispatchQueue.main) + .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) + } + } + } + + 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"), + accessibilityIdentifier: "InvalidAddressToast" + ) + case .insufficientSavings: + toast( + type: .error, + title: t("other__pay_insufficient_savings"), + 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"), + accessibilityIdentifier: "InsufficientSpendingToast" + ) + case .expiredLightningOnly: + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__expired"), + accessibilityIdentifier: "ExpiredLightningToast" + ) + case .valid, .empty: + break + } + } + // Convenience initializer for previews and testing convenience init() { self.init(sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel()) @@ -170,6 +235,18 @@ extension AppViewModel { switch data { // BIP21 (Unified) invoice handling case let .onChain(invoice): + // Check network first - treat wrong network as decoding error + let addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + 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.") @@ -177,27 +254,98 @@ 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) { + // Check lightning invoice network + let lnNetwork = NetworkValidationHelper.convertNetworkType(lightningInvoice.networkType) + let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) + + let canSend = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) + + if lnNetworkMatch, !lightningInvoice.isExpired, canSend { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } + + // 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 + let onchainBalance = lightningService.balances?.spendableOnchainBalanceSats ?? 0 + if invoice.amountSatoshis > 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)] + ), + accessibilityIdentifier: "InsufficientSavingsToast" + ) + 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"), + description: t("other__pay_insufficient_savings_description"), + accessibilityIdentifier: "InsufficientSavingsToast" + ) + return } } - // No LN invoice found, proceed with onchain payment handleScannedOnchainInvoice(invoice) case let .lightning(invoice): + // Check network first - treat wrong network as decoding error + let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } + guard lightningService.status?.isRunning == true else { toast(type: .error, title: "Lightning not running", description: "Please try again later.") return } - Logger.debug("Lightning: \(invoice)") - if lightningService.canSend(amountSats: invoice.amountSatoshis) { - handleScannedLightningInvoice(invoice, bolt11: uri) - } else { - toast(type: .error, title: "Insufficient Funds", description: "You do not have enough funds to send this payment.") + 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: description, + accessibilityIdentifier: "InsufficientSpendingToast" + ) + return } + + guard !invoice.isExpired else { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__expired"), + accessibilityIdentifier: "ExpiredLightningToast" + ) + return + } + + handleScannedLightningInvoice(invoice, bolt11: uri) case let .lnurlPay(data: lnurlPayData): Logger.debug("LNURL: \(lnurlPayData)") handleLnurlPayInvoice(lnurlPayData) @@ -216,7 +364,17 @@ extension AppViewModel { return } - // TODO: add network check + // Check network - treat wrong network as decoding error + let nodeNetwork = NetworkValidationHelper.convertNetworkType(network) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: nodeNetwork, currentNetwork: Env.network) { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } handleNodeUri(url, network) case let .gift(code, amount): @@ -358,23 +516,123 @@ extension AppViewModel { manualEntryValidationSequence &+= 1 manualEntryInput = "" isManualEntryInputValid = false + manualEntryValidationResult = .empty } - func validateManualEntryInput(_ rawValue: String) async { + /// 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) guard !normalized.isEmpty else { + manualEntryValidationResult = .empty isManualEntryInputValid = false return } - let isValid = await (try? decode(invoice: normalized)) != nil + // 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 { + 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 + } guard currentSequence == manualEntryValidationSequence else { return } - isManualEntryInputValid = isValid + + // Determine validation result based on invoice type and balance + var result: ManualEntryValidationResult = .valid + + switch decodedData { + case let .lightning(invoice): + // Priority 0: Check network first - treat wrong network as invalid + let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType) + if NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) { + result = .invalid + break + } + + // 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): + // Priority 0: Check network first - treat wrong network as invalid + let addressNetwork = NetworkValidationHelper.getAddressNetwork(invoice.address) + if NetworkValidationHelper.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 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) + + // Has lightning fallback - check if lightning is viable + canPayLightning = lnNetworkMatch && !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 } + + manualEntryValidationResult = result + isManualEntryInputValid = (result == .valid) + + // Show toast for error results + if result != .valid && result != .empty { + showValidationErrorToast(for: result) + } } } @@ -564,6 +822,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 { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 59176bf1..11ed8fce 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -523,7 +523,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 58d845ee..74c23dc8 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 + ) } ) } @@ -40,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") @@ -72,12 +79,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) @@ -88,5 +96,6 @@ struct SendEnterManuallyView: View { #Preview { SendEnterManuallyView(navigationPath: .constant([])) .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) .preferredColorScheme(.dark) } 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)) + } +}