From 06e820995396b4a86dcd88cad79e676a3e59799b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 24 Aug 2026 15:50:34 +0200 Subject: [PATCH] fix(explore-dash): stop per-foreground IP geolocation lookup checkGeoRestriction is invoked from applicationDidBecomeActive, and checkRestriction unconditionally issued a request to ip-api.com. That disclosed the user's IP address and an app-open timestamp to a third-party geolocation service on every foreground, to decide whether to hide one gift-card provider. PIGGYCARDS_ENABLED is set in Release and Testflight, so this shipped. Nothing throttled it: hasCheckedRestriction was assigned but never read. The request also went out even when GPS had already resolved the country and would win the priority check, because all sources were awaited up front. Country detection now uses on-device sources only, in the order GPS -> App Store storefront -> device region. Guard on hasCheckedRestriction so the work happens once per launch; leave the flag false when nothing resolves so a later call retries. Live GPS updates still refresh the status via setupLocationObserver. Also fixes the App Store branch, which could never match: StoreKit's Storefront.countryCode is ISO 3166-1 alpha-3 (init(iso3ACountryCode:)), while restrictedCountryCodes held alpha-2, so a Russian user without location permission got "RUS" and was silently treated as unrestricted. The set now carries both forms. Drops the DetectionSource.ipGeolocation case, which nothing can produce any more, and the per-invocation debug logging. Co-Authored-By: Claude Fable 5 --- DashWallet/AppDelegate.m | 4 +- .../Models/Explore Dash/ExploreDash.swift | 4 +- .../Services/GeoRestrictionService.swift | 113 +++++------------- 3 files changed, 37 insertions(+), 84 deletions(-) diff --git a/DashWallet/AppDelegate.m b/DashWallet/AppDelegate.m index b0616620c8..cfaedcd452 100644 --- a/DashWallet/AppDelegate.m +++ b/DashWallet/AppDelegate.m @@ -197,8 +197,8 @@ - (void)applicationDidBecomeActive:(UIApplication *)application { // [self.balanceNotifier updateBalance]; - // Check geo-restriction for PiggyCards (if available) - // This logs location info each time the app becomes active for debugging + // Check geo-restriction for PiggyCards (if available). No-ops once the + // country has been resolved for this launch. SEL checkGeoRestrictionSelector = NSSelectorFromString(@"checkGeoRestriction"); if ([ExploreDashObjcWrapper respondsToSelector:checkGeoRestrictionSelector]) { #pragma clang diagnostic push diff --git a/DashWallet/Sources/Models/Explore Dash/ExploreDash.swift b/DashWallet/Sources/Models/Explore Dash/ExploreDash.swift index c440f2413c..b58e856a98 100644 --- a/DashWallet/Sources/Models/Explore Dash/ExploreDash.swift +++ b/DashWallet/Sources/Models/Explore Dash/ExploreDash.swift @@ -83,7 +83,9 @@ public class ExploreDashObjcWrapper: NSObject { } #if PIGGYCARDS_ENABLED - /// Check geo-restriction status. Call this when the app becomes active to log location info. + /// Resolve the user's country if it has not been resolved yet this launch. + /// Cheap to call repeatedly: `checkRestriction()` returns immediately once a + /// country has been determined. @objc public class func checkGeoRestriction() { Task { @MainActor in await GeoRestrictionService.shared.checkRestriction() diff --git a/DashWallet/Sources/Models/Explore Dash/Services/GeoRestrictionService.swift b/DashWallet/Sources/Models/Explore Dash/Services/GeoRestrictionService.swift index 1ed27711eb..b685318bb3 100644 --- a/DashWallet/Sources/Models/Explore Dash/Services/GeoRestrictionService.swift +++ b/DashWallet/Sources/Models/Explore Dash/Services/GeoRestrictionService.swift @@ -41,8 +41,11 @@ func isPiggyCardsGeoRestricted() -> Bool { class GeoRestrictionService { static let shared = GeoRestrictionService() - /// Country codes that are restricted from using PiggyCards - private let restrictedCountryCodes: Set = ["RU", "CU"] + /// Country codes that are restricted from using PiggyCards, listed in both + /// ISO 3166-1 alpha-2 and alpha-3 form because the sources disagree: + /// CoreLocation's `isoCountryCode` and `Locale.Region` are alpha-2, while + /// StoreKit's `Storefront.countryCode` is alpha-3. + private let restrictedCountryCodes: Set = ["RU", "RUS", "CU", "CUB"] /// UserDefaults keys for thread-safe access private enum Keys { @@ -75,8 +78,8 @@ class GeoRestrictionService { enum DetectionSource: String { case gpsLocation = "GPS Location" - case ipGeolocation = "IP Geolocation" case appStore = "App Store" + case deviceRegion = "Device Region" case unknown = "Unknown" } @@ -91,11 +94,6 @@ class GeoRestrictionService { detectionSource = DetectionSource(rawValue: sourceString) } - DWLogger.log("🌍 GeoRestrictionService: Initialized") - DWLogger.log("🌍 GeoRestrictionService: Persisted restriction status: \(isPiggyCardsRestricted)") - DWLogger.log("🌍 GeoRestrictionService: Persisted country code: \(detectedCountryCode ?? "nil")") - DWLogger.log("🌍 GeoRestrictionService: Persisted detection source: \(detectionSource?.rawValue ?? "nil")") - // Listen for location changes to update restriction status setupLocationObserver() } @@ -111,90 +109,49 @@ class GeoRestrictionService { .store(in: &cancellables) } - /// Check if PiggyCards is restricted for the current user - /// This should be called when the app needs to determine PiggyCards availability + /// Resolve the user's country and cache the resulting restriction status. + /// + /// Every source consulted here is local to the device — none of them + /// contacts a remote service, so opening the app does not disclose the + /// user's IP address to a third party. + /// + /// Runs at most once per launch: the first call that resolves a country + /// sets `hasCheckedRestriction`. Later GPS fixes still refresh the status + /// through `setupLocationObserver`, and `refreshRestriction()` forces a + /// re-check. func checkRestriction() async { - DWLogger.log("🌍 GeoRestrictionService: ========== LOCATION CHECK START ==========") - - // Gather all location attributes for logging - let gpsAuthorized = DWLocationManager.shared.isAuthorized - let gpsCountry = DWLocationManager.shared.currentPlacemark?.isoCountryCode - let ipCountry = await fetchCountryFromIP() - let appStoreCountry = await fetchAppStoreCountry() - - DWLogger.log("🌍 GeoRestrictionService: 1. GPS Location:") - DWLogger.log("🌍 - Permission granted: \(gpsAuthorized)") - DWLogger.log("🌍 - Country code: \(gpsCountry ?? "nil")") - - DWLogger.log("🌍 GeoRestrictionService: 2. IP Geolocation:") - DWLogger.log("🌍 - Country code: \(ipCountry ?? "nil")") - - DWLogger.log("🌍 GeoRestrictionService: 3. App Store:") - DWLogger.log("🌍 - Country code: \(appStoreCountry ?? "nil")") + guard !hasCheckedRestriction else { return } - DWLogger.log("🌍 GeoRestrictionService: ========================================") - - // Priority: GPS -> IP -> App Store - if gpsAuthorized, let countryCode = gpsCountry { - DWLogger.log("🌍 GeoRestrictionService: ✅ Using GPS location: \(countryCode)") + // Priority: GPS -> App Store storefront -> device region. + if DWLocationManager.shared.isAuthorized, + let countryCode = DWLocationManager.shared.currentPlacemark?.isoCountryCode { updateRestrictionStatus(countryCode: countryCode, source: .gpsLocation) return } - if let countryCode = ipCountry { - DWLogger.log("🌍 GeoRestrictionService: ✅ Using IP geolocation: \(countryCode)") - updateRestrictionStatus(countryCode: countryCode, source: .ipGeolocation) + if let countryCode = await fetchAppStoreCountry() { + updateRestrictionStatus(countryCode: countryCode, source: .appStore) return } - if let countryCode = appStoreCountry { - DWLogger.log("🌍 GeoRestrictionService: ✅ Using App Store country: \(countryCode)") - updateRestrictionStatus(countryCode: countryCode, source: .appStore) + if let countryCode = Locale.current.region?.identifier { + updateRestrictionStatus(countryCode: countryCode, source: .deviceRegion) return } - // If all methods fail, assume not restricted - DWLogger.log("🌍 GeoRestrictionService: ⚠️ Unable to determine country, assuming not restricted") + // Nothing resolved. Leave `hasCheckedRestriction` false so a later call + // can retry once a source becomes available. + DWLogger.log("GeoRestrictionService: country undetermined, leaving PiggyCards unrestricted") isPiggyCardsRestricted = false detectedCountryCode = nil detectionSource = .unknown } - /// Fetch country code from IP geolocation service - private func fetchCountryFromIP() async -> String? { - // Using ip-api.com - free, no API key required, returns country code - guard let url = URL(string: "https://ip-api.com/json/?fields=countryCode") else { - return nil - } - - do { - let (data, response) = try await URLSession.shared.data(from: url) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - DWLogger.log("GeoRestrictionService: IP geolocation request failed") - return nil - } - - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let countryCode = json["countryCode"] as? String { - DWLogger.log("GeoRestrictionService: IP geolocation returned country: \(countryCode)") - return countryCode - } - } catch { - DWLogger.log("GeoRestrictionService: IP geolocation error: \(error.localizedDescription)") - } - - return nil - } - - /// Fetch country code from App Store storefront + /// Fetch country code from App Store storefront. Returns an ISO 3166-1 + /// alpha-3 code, which `restrictedCountryCodes` accounts for. private func fetchAppStoreCountry() async -> String? { - if let storefront = await Storefront.current { - DWLogger.log("GeoRestrictionService: App Store country: \(storefront.countryCode)") - return storefront.countryCode - } - return nil + guard let storefront = await Storefront.current else { return nil } + return storefront.countryCode } /// Update the restriction status based on detected country @@ -202,18 +159,12 @@ class GeoRestrictionService { let normalizedCode = countryCode.uppercased() let isRestricted = restrictedCountryCodes.contains(normalizedCode) - DWLogger.log("🌍 GeoRestrictionService: updateRestrictionStatus called") - DWLogger.log("🌍 GeoRestrictionService: Country code: \(normalizedCode)") - DWLogger.log("🌍 GeoRestrictionService: Source: \(source.rawValue)") - DWLogger.log("🌍 GeoRestrictionService: Is restricted country: \(isRestricted)") - DWLogger.log("🌍 GeoRestrictionService: Restricted countries list: \(restrictedCountryCodes)") - self.detectedCountryCode = normalizedCode self.detectionSource = source self.isPiggyCardsRestricted = isRestricted self.hasCheckedRestriction = true - DWLogger.log("🌍 GeoRestrictionService: ✅ Restriction status updated - isPiggyCardsRestricted = \(isRestricted)") + DWLogger.log("GeoRestrictionService: \(normalizedCode) via \(source.rawValue), PiggyCards restricted: \(isRestricted)") } /// Force refresh the restriction check