diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 268cee34..8579743c 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -55,6 +55,7 @@ jobs: CHATWOOT_API: ${{ secrets.CHATWOOT_API }} SIMULATOR_NAME: "iPhone 17" OS_VERSION: "latest" + GEO: false run: | echo "=== Building iOS app ===" echo "Using simulator: $SIMULATOR_NAME (iOS $OS_VERSION)" diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 884e39e9..720f1086 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -89,6 +89,7 @@ Models/LnPeer.swift, Models/Toast.swift, Services/CoreService.swift, + Services/GeoService.swift, Services/LightningService.swift, Services/MigrationsService.swift, Services/ServiceQueue.swift, @@ -113,6 +114,7 @@ Models/ReceivedTxSheetDetails.swift, Models/Toast.swift, Services/CoreService.swift, + Services/GeoService.swift, Services/LightningService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, @@ -591,7 +593,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG CHECK_GEOBLOCK $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -645,6 +647,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "CHECK_GEOBLOCK $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index e4bc7fe2..af446d36 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -16,6 +16,12 @@ enum Env { #endif static let dustLimit = 547 + #if CHECK_GEOBLOCK + static let isGeoblockingEnabled = true + #else + static let isGeoblockingEnabled = ProcessInfo.processInfo.environment["GEO"] == "true" + #endif + /// The current execution context of the app static var currentExecutionContext: ExecutionContext { return Bundle.main.bundleIdentifier?.lowercased().contains("notification") == true ? .pushNotificationExtension : .foregroundApp diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 228df375..20441fde 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1378,11 +1378,15 @@ class CoreService { } func checkGeoStatus() async throws -> Bool? { - try await ServiceQueue.background(.core) { + if !Env.isGeoblockingEnabled { + return false + } + + return try await ServiceQueue.background(.core) { Logger.info("Checking geo status...", context: "GeoCheck") guard let url = URL(string: Env.geoCheckUrl) else { Logger.error("Invalid geocheck URL: \(Env.geoCheckUrl)", context: "GeoCheck") - return nil + return nil as Bool? } let (_, response) = try await URLSession.shared.data(from: url) @@ -1397,10 +1401,10 @@ class CoreService { return true default: Logger.warn("Unexpected status code: \(httpResponse.statusCode)", context: "GeoCheck") - return nil + return nil as Bool? } } - return nil + return nil as Bool? } } } diff --git a/Bitkit/Services/GeoService.swift b/Bitkit/Services/GeoService.swift new file mode 100644 index 00000000..725643c8 --- /dev/null +++ b/Bitkit/Services/GeoService.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftUI + +/// Service responsible for managing geoblocking state +class GeoService: ObservableObject { + static let shared = GeoService() + + /// Current geoblocking status + /// - `false`: User is not geoblocked (default/fallback if check fails) + /// - `true`: User is geoblocked + @Published var isGeoBlocked: Bool = false + + private let coreService: CoreService + + private init(coreService: CoreService = .shared) { + self.coreService = coreService + } + + /// Checks the current geoblocking status and updates the published state + func checkGeoStatus() async { + do { + let result = try await coreService.checkGeoStatus() + + let newValue = result ?? false + + await MainActor.run { + self.isGeoBlocked = newValue + } + + Logger.info("Geo status check completed: isGeoBlocked=\(isGeoBlocked)", context: "GeoService") + } catch { + isGeoBlocked = false + Logger.error("Failed to check geo status: \(error)", context: "GeoService") + } + } +} diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 88c9c3e0..971692c4 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -298,7 +298,7 @@ class LightningService { } /// Checks if we have the correct outbound capacity to send the amount - /// - Parameter amountSats + /// - Parameter amountSats: Amount to send in satoshis /// - Returns: True if we can send the amount func canSend(amountSats: UInt64) -> Bool { guard let channels else { @@ -306,11 +306,15 @@ class LightningService { return false } + // When geoblocked, only count non-LSP channels + let isGeoblocked = GeoService.shared.isGeoBlocked + let channelsToUse = isGeoblocked ? getNonLspChannels() : channels + let totalNextOutboundHtlcLimitSats = - channels + channelsToUse .filter(\.isUsable) .map(\.nextOutboundHtlcLimitMsat) - .reduce(0, +) * 1000 + .reduce(0, +) / 1000 guard totalNextOutboundHtlcLimitSats > amountSats else { Logger.warn("Insufficient outbound capacity: \(totalNextOutboundHtlcLimitSats) < \(amountSats)") @@ -370,6 +374,16 @@ class LightningService { throw AppError(serviceError: .nodeNotSetup) } + // When geoblocked, verify we have external (non-LSP) peers + let isGeoblocked = GeoService.shared.isGeoBlocked + if isGeoblocked && !hasExternalPeers() { + Logger.error("Cannot send Lightning payment when geoblocked without external peers") + throw AppError( + message: "Lightning send unavailable", + debugMessage: "You need channels with non-Blocktank nodes to send Lightning payments." + ) + } + Logger.info("Paying bolt11: \(bolt11)") do { @@ -546,6 +560,31 @@ extension LightningService { var peers: [PeerDetails]? { node?.listPeers() } var channels: [ChannelDetails]? { node?.listChannels() } var payments: [PaymentDetails]? { node?.listPayments() } + + /// Returns LSP (Blocktank) peer node IDs + func getLspPeerNodeIds() -> [String] { + return Env.trustedLnPeers.map(\.nodeId) + } + + /// Checks if there are connected peers other than LSP peers + /// Used for geoblocking to determine if Lightning operations can proceed + func hasExternalPeers() -> Bool { + guard let peers else { return false } + let lspNodeIds = Set(getLspPeerNodeIds()) + return peers.contains { peer in + !lspNodeIds.contains(peer.nodeId) + } + } + + /// Filters channels to exclude LSP channels + /// Used for geoblocking to only allow operations through non-Blocktank channels + func getNonLspChannels() -> [ChannelDetails] { + guard let channels else { return [] } + let lspNodeIds = Set(getLspPeerNodeIds()) + return channels.filter { channel in + !lspNodeIds.contains(channel.counterpartyNodeId) + } + } } // MARK: Events diff --git a/Bitkit/Services/TransferService.swift b/Bitkit/Services/TransferService.swift index f7fdf80c..16f4a72c 100644 --- a/Bitkit/Services/TransferService.swift +++ b/Bitkit/Services/TransferService.swift @@ -32,6 +32,17 @@ class TransferService { fundingTxId: String? = nil, lspOrderId: String? = nil ) async throws -> String { + // When geoblocked, block transfers to spending that involve LSP (Blocktank) + // toSpending with lspOrderId means it's a Blocktank LSP channel order + let isGeoblocked = GeoService.shared.isGeoBlocked + if isGeoblocked && type.isToSpending() && lspOrderId != nil { + Logger.error("Cannot create LSP transfer when geoblocked", context: "TransferService") + throw AppError( + message: "Transfer unavailable", + debugMessage: "Transfer to spending via Blocktank is not available in your region." + ) + } + let id = UUID().uuidString let createdAt = UInt64(Date().timeIntervalSince1970) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index e670c724..e8dd0a1d 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -13,8 +13,6 @@ class AppViewModel: ObservableObject { @Published var lnurlPayData: LnurlPayData? @Published var lnurlWithdrawData: LnurlWithdrawData? - @Published var isGeoBlocked: Bool? = nil - // Onboarding @AppStorage("hasSeenContactsIntro") var hasSeenContactsIntro: Bool = false @AppStorage("hasSeenProfileIntro") var hasSeenProfileIntro: Bool = false @@ -91,11 +89,8 @@ class AppViewModel: ObservableObject { deinit {} func checkGeoStatus() async { - do { - isGeoBlocked = try await coreService.checkGeoStatus() - } catch { - Logger.error("Failed to check geo status: \(error)", context: "GeoCheck") - } + // Delegate to GeoService singleton for centralized geo-blocking management + await GeoService.shared.checkGeoStatus() } func wipe() async throws { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 950aa1b6..bf0e14ca 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -497,6 +497,27 @@ class WalletViewModel: ObservableObject { return capacity } + /// Total inbound Lightning capacity excluding LSP (Blocktank) channels + /// Used when geoblocked to show only non-Blocktank receiving capacity + var totalNonLspInboundLightningSats: UInt64? { + let nonLspChannels = lightningService.getNonLspChannels() + guard !nonLspChannels.isEmpty else { + return nil + } + + var capacity: UInt64 = 0 + for channel in nonLspChannels { + capacity += channel.inboundCapacityMsat / 1000 + } + return capacity + } + + /// Check if there are non-LSP (non-Blocktank) channels available + /// Used for geoblocking to determine if Lightning operations can proceed + func hasNonLspChannels() -> Bool { + return !lightningService.getNonLspChannels().isEmpty + } + func refreshBip21(forceRefreshBolt11: Bool = false) async throws { // Get old payment ID and tags before refreshing (which may change payment ID) let oldPaymentId = await paymentId() @@ -524,7 +545,15 @@ class WalletViewModel: ObservableObject { let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil - if channels?.count ?? 0 > 0 { + // When geoblocked, only create Lightning invoice if we have non-LSP channels + let isGeoblocked = GeoService.shared.isGeoBlocked + let hasUsableChannels: Bool = if isGeoblocked { + hasNonLspChannels() + } else { + channels?.count ?? 0 > 0 + } + + if hasUsableChannels { if forceRefreshBolt11 || bolt11.isEmpty { bolt11 = try await createInvoice(amountSats: amountSats, note: invoiceNote) } else { diff --git a/Bitkit/Views/Onboarding/OnboardingSlider.swift b/Bitkit/Views/Onboarding/OnboardingSlider.swift index dc780a8d..6af0bfe0 100644 --- a/Bitkit/Views/Onboarding/OnboardingSlider.swift +++ b/Bitkit/Views/Onboarding/OnboardingSlider.swift @@ -77,7 +77,7 @@ struct OnboardingSlider: View { imageName: "lightning", title: t("onboarding__slide1_header"), text: tTodo("Enjoy instant and cheap payments with friends, family, and merchants on the Lightning Network."), - disclaimerText: app.isGeoBlocked == true ? t("onboarding__slide1_note") : nil, + disclaimerText: GeoService.shared.isGeoBlocked ? t("onboarding__slide1_note") : nil, accentColor: .purpleAccent ) .tag(1) diff --git a/Bitkit/Views/Transfer/FundingOptions.swift b/Bitkit/Views/Transfer/FundingOptions.swift index bd6a7ed0..047e33e5 100644 --- a/Bitkit/Views/Transfer/FundingOptions.swift +++ b/Bitkit/Views/Transfer/FundingOptions.swift @@ -7,7 +7,7 @@ struct FundingOptions: View { @EnvironmentObject var wallet: WalletViewModel var text: String { - if app.isGeoBlocked == true { + if GeoService.shared.isGeoBlocked { return t("lightning__funding__text_blocked") } else { return t("lightning__funding__text") @@ -33,7 +33,7 @@ struct FundingOptions: View { RectangleButton( icon: "transfer", title: t("lightning__funding__button1"), - isDisabled: wallet.totalOnchainSats == 0 || app.isGeoBlocked == true, + isDisabled: wallet.totalOnchainSats == 0 || GeoService.shared.isGeoBlocked, testID: "FundTransfer" ) { if app.hasSeenTransferToSpendingIntro { @@ -46,7 +46,7 @@ struct FundingOptions: View { RectangleButton( icon: "qr", title: t("lightning__funding__button2"), - isDisabled: app.isGeoBlocked == true, + isDisabled: GeoService.shared.isGeoBlocked, testID: "FundReceive" ) { navigation.reset() diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index 1216c170..eca166e0 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -172,9 +172,12 @@ struct ReceiveEdit: View { } private func needsAdditionalCjit() -> Bool { - let isGeoBlocked = app.isGeoBlocked ?? false + let isGeoBlocked = GeoService.shared.isGeoBlocked let minimumAmount = blocktank.minCjitSats ?? 0 - let inboundCapacity = wallet.totalInboundLightningSats ?? 0 + // When geoblocked, only count non-LSP inbound capacity + let inboundCapacity = isGeoBlocked + ? (wallet.totalNonLspInboundLightningSats ?? 0) + : (wallet.totalInboundLightningSats ?? 0) let invoiceAmount = amountViewModel.amountSats // Calculate maxClientBalance using TransferViewModel diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift index 995bcd66..a88f0e09 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift @@ -42,9 +42,17 @@ struct ReceiveQr: View { } } + private var hasUsableChannels: Bool { + if GeoService.shared.isGeoBlocked { + return wallet.hasNonLspChannels() + } else { + return wallet.channelCount != 0 + } + } + private var availableTabItems: [TabItem] { - // Only show unified tab if there are channels - if wallet.channelCount != 0 { + // Only show unified tab if there are usable channels + if hasUsableChannels { return [ TabItem(.savings), TabItem(.unified, activeColor: .white), @@ -59,7 +67,12 @@ struct ReceiveQr: View { } var showingCjitOnboarding: Bool { - return wallet.channelCount == 0 && cjitInvoice == nil && selectedTab == .spending + // Show CJIT onboarding when: + // 1. No channels at all, OR + // 2. Geoblocked with only Blocktank channels (treat as no usable channels) + let hasNoUsableChannels = (wallet.channelCount == 0) || + (GeoService.shared.isGeoBlocked && !wallet.hasNonLspChannels()) + return hasNoUsableChannels && cjitInvoice == nil && selectedTab == .spending } var body: some View { @@ -75,7 +88,7 @@ struct ReceiveQr: View { TabView(selection: $selectedTab) { tabContent(for: .savings) - if wallet.channelCount != 0 { + if hasUsableChannels { tabContent(for: .unified) } @@ -96,7 +109,15 @@ struct ReceiveQr: View { .foregroundColor(.purpleAccent), isDisabled: wallet.nodeLifecycleState != .running ) { - navigationPath.append(.cjitAmount) + if GeoService.shared.isGeoBlocked && !wallet.hasNonLspChannels() { + app.toast( + type: .error, + title: "Instant Payments Unavailable", + description: "Bitkit does not provide Lightning services in your country, but you can still connect to other nodes." + ) + } else { + navigationPath.append(.cjitAmount) + } } } else { CustomButton(title: showDetails ? tTodo("QR Code") : tTodo("Show Details")) { @@ -128,6 +149,7 @@ struct ReceiveQr: View { } catch { app.toast(error) } + try? await app.checkGeoStatus() } .onChange(of: wallet.nodeLifecycleState) { newState in // They may open this view before node has started @@ -142,7 +164,7 @@ struct ReceiveQr: View { @ViewBuilder func tabContent(for tab: ReceiveTab) -> some View { VStack(spacing: 0) { - if tab == .spending && wallet.channelCount == 0 && cjitInvoice == nil { + if showingCjitOnboarding { cjitOnboarding } else if showDetails { detailsContent(for: tab) diff --git a/Bitkit/Views/Wallets/SavingsWalletView.swift b/Bitkit/Views/Wallets/SavingsWalletView.swift index b5ae0386..efd0697d 100644 --- a/Bitkit/Views/Wallets/SavingsWalletView.swift +++ b/Bitkit/Views/Wallets/SavingsWalletView.swift @@ -25,7 +25,7 @@ struct SavingsWalletView: View { } if wallet.totalOnchainSats > 0 { - if !(app.isGeoBlocked ?? true) { + if !GeoService.shared.isGeoBlocked { transferButton .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, 32)