From ea2cccddf669ff55e1472d3ea9876d7ae0a17133 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:26:43 +0200 Subject: [PATCH 01/27] feat: persist QuickPay daily spend Track USD spend on the local calendar day so QuickPay can enforce a daily cap. --- Bitkit/Utilities/QuickPayLimits.swift | 46 ++++++++++++++ Bitkit/Utilities/QuickPaySpendStore.swift | 69 ++++++++++++++++++++ BitkitTests/QuickPaySpendStoreTests.swift | 77 +++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 Bitkit/Utilities/QuickPayLimits.swift create mode 100644 Bitkit/Utilities/QuickPaySpendStore.swift create mode 100644 BitkitTests/QuickPaySpendStoreTests.swift diff --git a/Bitkit/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift new file mode 100644 index 000000000..baf806685 --- /dev/null +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -0,0 +1,46 @@ +import Foundation + +enum QuickPayLimits { + static let thresholdSteps: [Double] = [1, 5, 10, 20, 50] + static let dailyMultiplierSteps: [Double] = [1, 3, 5, 10, 50] + static let defaultThresholdUsd: Double = 5 + static let defaultDailyMultiplier: Double = 5 + + static func sanitizedMultiplier(_ value: Double) -> Double { + dailyMultiplierSteps.contains(value) ? value : defaultDailyMultiplier + } + + static func dailyCapUsdDisplay(thresholdUsd: Double, multiplier: Double) -> Int { + Int(thresholdUsd) * Int(multiplier) + } + + @MainActor + static func paymentAmountSats(app: AppViewModel) -> UInt64? { + if let lnurlPayData = app.lnurlPayData { + guard lnurlPayData.isFixedAmount else { return nil } + return lnurlPayData.minSendableSat + } + + return app.scannedLightningInvoice?.amountSatoshis + } + + @MainActor + static func dailyCapUsd( + thresholdUsd: Double, + multiplier: Double, + currency: CurrencyViewModel + ) -> Double? { + guard let thresholdSats = currency.convert(fiatAmount: thresholdUsd, from: "USD"), thresholdSats > 0 else { + return nil + } + + let dailyCapSats = thresholdSats * UInt64(max(multiplier, 1).rounded()) + return usdValue(sats: dailyCapSats, currency: currency) + } + + @MainActor + static func usdValue(sats: UInt64, currency: CurrencyViewModel) -> Double? { + guard let converted = currency.convert(sats: sats, to: "USD") else { return nil } + return (converted.value as NSDecimalNumber).doubleValue + } +} diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift new file mode 100644 index 000000000..62a6057f5 --- /dev/null +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -0,0 +1,69 @@ +import Foundation + +final class QuickPaySpendStore: @unchecked Sendable { + static let shared = QuickPaySpendStore() + + static let dayKeyDefaultsKey = "quickPaySpendDayKey" + static let spentUsdDefaultsKey = "quickPaySpentUsdToday" + + private let defaults: UserDefaults + private let lock = NSLock() + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + static func dayKey(date: Date = Date(), timeZone: TimeZone = .current) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + func spentUsd(forDayKey dayKey: String) -> Double { + lock.lock() + defer { lock.unlock() } + return lockedSpentUsd(forDayKey: dayKey) + } + + @discardableResult + func tryReserve(amountUsd: Double, dayKey: String, dailyCapUsd: Double) -> Bool { + lock.lock() + defer { lock.unlock() } + + let spent = lockedSpentUsd(forDayKey: dayKey) + if spent + amountUsd > dailyCapUsd { + return false + } + + lockedWrite(dayKey: dayKey, spentUsd: spent + amountUsd) + return true + } + + func release(amountUsd: Double, dayKey: String) { + lock.lock() + defer { lock.unlock() } + + guard defaults.string(forKey: Self.dayKeyDefaultsKey) == dayKey else { return } + let spent = defaults.double(forKey: Self.spentUsdDefaultsKey) + lockedWrite(dayKey: dayKey, spentUsd: max(spent - amountUsd, 0)) + } + + func record(amountUsd: Double, dayKey: String) { + lock.lock() + defer { lock.unlock() } + + let spent = lockedSpentUsd(forDayKey: dayKey) + lockedWrite(dayKey: dayKey, spentUsd: spent + amountUsd) + } + + private func lockedSpentUsd(forDayKey dayKey: String) -> Double { + guard defaults.string(forKey: Self.dayKeyDefaultsKey) == dayKey else { return 0 } + return defaults.double(forKey: Self.spentUsdDefaultsKey) + } + + private func lockedWrite(dayKey: String, spentUsd: Double) { + defaults.set(dayKey, forKey: Self.dayKeyDefaultsKey) + defaults.set(spentUsd, forKey: Self.spentUsdDefaultsKey) + } +} diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift new file mode 100644 index 000000000..841941545 --- /dev/null +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -0,0 +1,77 @@ +@testable import Bitkit +import XCTest + +final class QuickPaySpendStoreTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + private var sut: QuickPaySpendStore! + + override func setUp() { + super.setUp() + suiteName = "QuickPaySpendStoreTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + sut = QuickPaySpendStore(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + sut = nil + super.tearDown() + } + + func testDayKeyUsesLocalCalendarDate() throws { + var calendar = Calendar(identifier: .gregorian) + let timeZone = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles")) + calendar.timeZone = timeZone + let date = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 8, day: 15, hour: 23, minute: 30))) + + XCTAssertEqual(QuickPaySpendStore.dayKey(date: date, timeZone: timeZone), "2026-08-15") + } + + func testSpentUsdReturnsSpendForMatchingDayKey() { + sut.record(amountUsd: 3.5, dayKey: "2026-08-15") + + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 3.5) + } + + func testSpentUsdReturnsZeroForADifferentDayKey() { + sut.record(amountUsd: 12.0, dayKey: "2026-08-14") + + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + } + + func testRecordAccumulatesOnTheSameDayAndResetsOnANewDay() { + sut.record(amountUsd: 2.0, dayKey: "2026-08-15") + sut.record(amountUsd: 1.5, dayKey: "2026-08-15") + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 3.5) + + sut.record(amountUsd: 4.0, dayKey: "2026-08-16") + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-16"), 4.0) + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + } + + func testReserveAcceptsSpendUnderTheCapAndRejectsOverIt() { + XCTAssertTrue(sut.tryReserve(amountUsd: 10.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + XCTAssertTrue(sut.tryReserve(amountUsd: 10.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + XCTAssertFalse(sut.tryReserve(amountUsd: 10.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 20.0) + } + + func testReserveAllowsSpendThatEqualsTheCap() { + XCTAssertTrue(sut.tryReserve(amountUsd: 25.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 25.0) + } + + func testReleaseRollsBackAReservation() { + XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + sut.release(amountUsd: 5.0, dayKey: "2026-08-15") + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + } + + func testReleaseDoesNotChangeSpendForADifferentDay() { + sut.record(amountUsd: 7.0, dayKey: "2026-08-16") + sut.release(amountUsd: 7.0, dayKey: "2026-08-15") + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-16"), 7.0) + } +} From 01d36caa5b5cb8b9adee09ce240f134b6b1fee22 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:26:45 +0200 Subject: [PATCH 02/27] feat: add QuickPay daily limit setting Expose the Android daily multiplier steps and resolved dollar cap in settings, and keep the value in backup. --- Bitkit/Components/CustomSlider.swift | 5 ++- Bitkit/Models/SettingsBackupConfig.swift | 2 + .../Localization/en.lproj/Localizable.strings | 3 ++ Bitkit/ViewModels/SettingsViewModel.swift | 25 ++++++++--- .../Settings/Quickpay/QuickpaySettings.swift | 45 +++++++++++++++++-- BitkitTests/AddressTypeSettingsTests.swift | 17 +++++++ 6 files changed, 87 insertions(+), 10 deletions(-) diff --git a/Bitkit/Components/CustomSlider.swift b/Bitkit/Components/CustomSlider.swift index a27fcbd46..21400cba6 100644 --- a/Bitkit/Components/CustomSlider.swift +++ b/Bitkit/Components/CustomSlider.swift @@ -3,6 +3,8 @@ import SwiftUI struct CustomSlider: View { @Binding var value: Double let steps: [Double] + var formatLabel: (Double) -> String = { "$\(Int($0))" } + var testIdentifier: String? = nil @State private var sliderIndex: Double = 0 @State private var sliderWidth: CGFloat = 0 @@ -75,6 +77,7 @@ struct CustomSlider: View { } } ) + .accessibilityIdentifierIfPresent(testIdentifier) .gesture( DragGesture(minimumDistance: 0) .onChanged { gesture in @@ -117,7 +120,7 @@ struct CustomSlider: View { // Step labels GeometryReader { geometry in ForEach(Array(steps.enumerated()), id: \.offset) { index, step in - Text("$\(Int(step))") + Text(formatLabel(step)) .font(.custom(Fonts.medium, size: 13)) .foregroundColor(.textPrimary) .position( diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 73c0738e1..1afb04728 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -53,6 +53,7 @@ enum SettingsBackupConfig { "backupVerified": .bool, "enableNotifications": .bool, "quickpayAmount": .double(optional: false), + "quickpayDailyLimitMultiplier": .double(optional: false), ] static var settingsKeys: [String] { @@ -65,6 +66,7 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", + "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", // Note: PIN settings are intentionally NOT backed up for security // PIN itself cannot be backed up, so PIN settings shouldn't be either diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index d726ff5ec..62cdc34eb 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -800,7 +800,10 @@ "settings__quickpay__settings__toggle" = "Enable QuickPay"; "settings__quickpay__settings__text" = "If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*."; "settings__quickpay__settings__label" = "Quickpay threshold"; +"settings__quickpay__settings__daily_label" = "Daily QuickPay limit"; +"settings__quickpay__settings__daily_text" = "Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm."; "settings__quickpay__settings__note" = "* Bitkit QuickPay exclusively supports payments from your Spending Balance."; +"wallet__send_quickpay__daily_limit" = "Daily QuickPay limit reached"; "settings__security__title" = "Security And Privacy"; "settings__security__swipe_balance_to_hide" = "Swipe balance to hide"; "settings__security__hide_balance_on_open" = "Hide balance on open"; diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 4c14cf7e7..c91a07aca 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -104,6 +104,7 @@ class SettingsViewModel: NSObject, ObservableObject { @AppStorage("warnWhenSendingOver100") var warnWhenSendingOver100: Bool = false @AppStorage("enableQuickpay") var enableQuickpay: Bool = false @AppStorage("quickpayAmount") var quickpayAmount: Double = 5 + @AppStorage("quickpayDailyLimitMultiplier") var quickpayDailyLimitMultiplier: Double = 5 @AppStorage("enableNotifications") var enableNotifications: Bool = false @AppStorage("enableNotificationsAmount") var enableNotificationsAmount: Bool = false @AppStorage("ignoresSwitchUnitToast") var ignoresSwitchUnitToast: Bool = false @@ -217,6 +218,7 @@ class SettingsViewModel: NSObject, ObservableObject { warnWhenSendingOver100 = false enableQuickpay = false quickpayAmount = 5 + quickpayDailyLimitMultiplier = 5 enableNotifications = false enableNotificationsAmount = false UserDefaults.standard.set(false, forKey: PaykitFeatureFlags.uiEnabledKey) @@ -383,11 +385,15 @@ class SettingsViewModel: NSObject, ObservableObject { } } } else { - if addressType == selectedAddressType { return false } + if addressType == selectedAddressType { + return false + } do { let balance = try await getBalanceForAddressType(addressType) - if balance > 0 { return false } + if balance > 0 { + return false + } } catch { Logger.error("Failed to check balance for \(addressType), preventing disable: \(error)") lastAddressTypeError = error @@ -467,7 +473,9 @@ class SettingsViewModel: NSObject, ObservableObject { for type in addressTypesToMonitor { // Always keep nativeSegwit (primary, required for Lightning) - if type == .nativeSegwit { continue } + if type == .nativeSegwit { + continue + } do { let balance = try await getBalanceForAddressType(type) @@ -696,7 +704,7 @@ class SettingsViewModel: NSObject, ObservableObject { dict["coinSelectPreference"] = androidPreference } else { let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[key] ?? key - if key == "quickpayAmount", let doubleValue = value as? Double { + if key == "quickpayAmount" || key == "quickpayDailyLimitMultiplier", let doubleValue = value as? Double { dict[androidKey] = Int(doubleValue) } else { dict[androidKey] = value @@ -706,10 +714,14 @@ class SettingsViewModel: NSObject, ObservableObject { } let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl - if !electrumServerUrl.isEmpty { dict["electrumServer"] = electrumServerUrl } + if !electrumServerUrl.isEmpty { + dict["electrumServer"] = electrumServerUrl + } let rgsServerUrl = rgsConfigService.getCurrentServerUrl() - if !rgsServerUrl.isEmpty { dict["rgsServerUrl"] = rgsServerUrl } + if !rgsServerUrl.isEmpty { + dict["rgsServerUrl"] = rgsServerUrl + } dict["isDevModeEnabled"] = Env.isDebug && Env.network != .bitcoin @@ -842,6 +854,7 @@ class SettingsViewModel: NSObject, ObservableObject { warnWhenSendingOver100 = defaults.bool(forKey: "warnWhenSendingOver100") enableQuickpay = defaults.bool(forKey: "enableQuickpay") quickpayAmount = defaults.double(forKey: "quickpayAmount") + quickpayDailyLimitMultiplier = QuickPayLimits.sanitizedMultiplier(defaults.double(forKey: "quickpayDailyLimitMultiplier")) enableNotifications = defaults.bool(forKey: "enableNotifications") requirePinForPayments = defaults.bool(forKey: "requirePinForPayments") useBiometrics = defaults.bool(forKey: "useBiometrics") diff --git a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift index ca920ec57..261f0150f 100644 --- a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift +++ b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift @@ -3,7 +3,12 @@ import SwiftUI struct QuickpaySettings: View { @EnvironmentObject private var settings: SettingsViewModel - private let sliderSteps: [Double] = [1, 5, 10, 20, 50] + private var dailyLimitUsd: Int { + QuickPayLimits.dailyCapUsdDisplay( + thresholdUsd: settings.quickpayAmount, + multiplier: settings.quickpayDailyLimitMultiplier + ) + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -26,7 +31,34 @@ struct QuickpaySettings: View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader(t("settings__quickpay__settings__label")) - CustomSlider(value: $settings.quickpayAmount, steps: sliderSteps) + CustomSlider( + value: $settings.quickpayAmount, + steps: QuickPayLimits.thresholdSteps, + testIdentifier: "QuickpayAmountSlider" + ) + } + .padding(.top, 32) + + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader(t("settings__quickpay__settings__daily_label")) + + BodyMText( + t( + "settings__quickpay__settings__daily_text", + variables: [ + "limit": String(dailyLimitUsd), + "multiplier": String(Int(settings.quickpayDailyLimitMultiplier)), + ] + ) + ) + .padding(.bottom, 16) + + CustomSlider( + value: $settings.quickpayDailyLimitMultiplier, + steps: QuickPayLimits.dailyMultiplierSteps, + formatLabel: { "\(Int($0))×" }, + testIdentifier: "QuickpayDailyLimitSlider" + ) } .padding(.top, 32) @@ -42,7 +74,6 @@ struct QuickpaySettings: View { } .frame(maxWidth: .infinity) .padding(.horizontal, 16) - // .padding(.vertical, 32) BodySText(t("settings__quickpay__settings__note")) } @@ -55,3 +86,11 @@ struct QuickpaySettings: View { .navigationBarHidden(true) } } + +#Preview { + NavigationStack { + QuickpaySettings() + .environmentObject(SettingsViewModel.shared) + .preferredColorScheme(.dark) + } +} diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index a68c34b78..f58e2757b 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -253,6 +253,7 @@ final class AddressTypeSettingsTests: XCTestCase { settings.addressTypesToMonitor = [.nativeSegwit, .taproot, .legacy] settings.hideBalance = true settings.enableQuickpay = true + settings.quickpayDailyLimitMultiplier = 10 UserDefaults.standard.synchronize() let backupDict = settings.getSettingsDictionary() @@ -274,5 +275,21 @@ final class AddressTypeSettingsTests: XCTestCase { "hideBalance should survive full backup→reset→restore cycle") XCTAssertEqual(settings.enableQuickpay, true, "enableQuickpay should survive full backup→reset→restore cycle") + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 10, + "quickpayDailyLimitMultiplier should survive full backup→reset→restore cycle") + XCTAssertEqual(backupDict["quickPayDailyLimitMultiplier"] as? Int, 10) + XCTAssertNil(backupDict["quickpayDailyLimitMultiplier"]) + } + + func testRestoresDailyLimitMultiplierFromAndroidKey() { + settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 3]) + + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 3) + } + + func testInvalidDailyLimitMultiplierFallsBackToDefault() { + settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) + + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 5) } } From fb49623b9067f8a0c379b2ed475d85d1c872dd65 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:26:49 +0200 Subject: [PATCH 03/27] fix: skip QuickPay over the daily cap Keep auto-pay PIN-free under the daily limit, record spend on success or pending, and send over-cap payments to Confirm. --- .../Utilities/PaymentNavigationHelper.swift | 47 ++++--- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 74 +++++++++-- .../PaymentNavigationHelperTests.swift | 123 ++++++++++++++++++ changelog.d/next/670.security.md | 1 + 4 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 BitkitTests/PaymentNavigationHelperTests.swift create mode 100644 changelog.d/next/670.security.md diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index a9c30fd11..05cf3b0d9 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -12,31 +12,41 @@ struct PaymentNavigationHelper { static func shouldUseQuickpay( app: AppViewModel, settings: SettingsViewModel, - currency: CurrencyViewModel + currency: CurrencyViewModel, + spendStore: QuickPaySpendStore = .shared ) -> Bool { - // Check if quickpay is enabled guard settings.enableQuickpay else { return false } - // We need a lightning invoice or LNURL pay data to use quickpay - guard app.scannedLightningInvoice != nil || app.lnurlPayData != nil else { + guard let amountSats = QuickPayLimits.paymentAmountSats(app: app), amountSats > 0 else { return false } - // Convert quickpay amount from USD to sats let quickpayAmountSats = currency.convert(fiatAmount: settings.quickpayAmount, from: "USD") ?? 0 - guard quickpayAmountSats > 0 else { + guard quickpayAmountSats > 0, amountSats <= quickpayAmountSats else { return false } - // Check LNURL pay - if let lnurlPayData = app.lnurlPayData { - return lnurlPayData.isFixedAmount && lnurlPayData.minSendableSat <= quickpayAmountSats + let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) + guard let dailyCapUsd = QuickPayLimits.dailyCapUsd( + thresholdUsd: settings.quickpayAmount, + multiplier: multiplier, + currency: currency + ), let amountUsd = QuickPayLimits.usdValue(sats: amountSats, currency: currency) else { + return false + } + + let dayKey = QuickPaySpendStore.dayKey() + let spentUsdToday = spendStore.spentUsd(forDayKey: dayKey) + if spentUsdToday + amountUsd > dailyCapUsd { + Logger.info( + "Skipping QuickPay: daily spend '\(spentUsdToday)' + '\(amountUsd)' exceeds cap '\(dailyCapUsd)'" + ) + return false } - // Check regular lightning invoice - return app.scannedLightningInvoice!.amountSatoshis <= quickpayAmountSats + return true } /// Centralized method to open the appropriate sheet based on the current state @@ -44,7 +54,8 @@ struct PaymentNavigationHelper { app: AppViewModel, currency: CurrencyViewModel, settings: SettingsViewModel, - sheetViewModel: SheetViewModel + sheetViewModel: SheetViewModel, + spendStore: QuickPaySpendStore = .shared ) { // Handle LNURL withdraw if let lnurlWithdrawData = app.lnurlWithdrawData { @@ -57,7 +68,7 @@ struct PaymentNavigationHelper { return } - let shouldUseQuickpay = shouldUseQuickpay(app: app, settings: settings, currency: currency) + let shouldUseQuickpay = shouldUseQuickpay(app: app, settings: settings, currency: currency, spendStore: spendStore) // Handle Lightning address / LNURL pay if let lnurlPayData = app.lnurlPayData { @@ -100,7 +111,8 @@ struct PaymentNavigationHelper { static func appropriateSendRoute( app: AppViewModel, currency: CurrencyViewModel, - settings: SettingsViewModel + settings: SettingsViewModel, + spendStore: QuickPaySpendStore = .shared ) -> SendRoute? { if let lnurlWithdrawData = app.lnurlWithdrawData { if lnurlWithdrawData.isFixedAmount { @@ -110,7 +122,7 @@ struct PaymentNavigationHelper { } } - let shouldUseQuickpay = shouldUseQuickpay(app: app, settings: settings, currency: currency) + let shouldUseQuickpay = shouldUseQuickpay(app: app, settings: settings, currency: currency, spendStore: spendStore) // Handle Lightning address / LNURL pay if let lnurlPayData = app.lnurlPayData { @@ -150,9 +162,10 @@ struct PaymentNavigationHelper { static func contactPaymentRoute( app: AppViewModel, currency: CurrencyViewModel, - settings: SettingsViewModel + settings: SettingsViewModel, + spendStore: QuickPaySpendStore = .shared ) -> SendRoute? { - guard let route = appropriateSendRoute(app: app, currency: currency, settings: settings) else { + guard let route = appropriateSendRoute(app: app, currency: currency, settings: settings, spendStore: spendStore) else { return nil } diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 63e7efb50..16c2b897e 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -3,11 +3,14 @@ import SwiftUI struct SendQuickpay: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @Binding var navigationPath: [SendRoute] let routingCacheResetAttempted: Bool + var spendStore: QuickPaySpendStore = .shared var body: some View { VStack { @@ -64,21 +67,32 @@ struct SendQuickpay: View { ) } + let amountSats = wallet.sendAmountSats ?? 0 + let reservation = try reserveDailySpend(amountSats: amountSats) + let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) // Quickpay only triggers for invoices with built-in amounts, so pass sats: nil // to let LDK use the invoice's native millisatoshi precision. - try await wallet.sendWithTimeout( - bolt11: bolt11, - sats: nil, - onTimeout: { - app.addPendingPaymentHash(paymentHash) - navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .quickpay, paymentRequest: bolt11)) - } - ) - Logger.info("Quickpay payment successful: \(paymentHash)") - navigationPath.append(.success(paymentId: paymentHash)) + do { + try await wallet.sendWithTimeout( + bolt11: bolt11, + sats: nil, + onTimeout: { + app.addPendingPaymentHash(paymentHash) + navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .quickpay, paymentRequest: bolt11)) + } + ) + Logger.info("Quickpay payment successful: \(paymentHash)") + navigationPath.append(.success(paymentId: paymentHash)) + } catch is PaymentTimeoutError { + // Pending keeps the reserved spend; onTimeout already navigated. + return + } catch { + reservation.release() + throw error + } } catch is PaymentTimeoutError { // onTimeout callback already navigated to .pending; suppress throw return @@ -87,6 +101,36 @@ struct SendQuickpay: View { } } + private func reserveDailySpend(amountSats: UInt64) throws -> ReservedQuickPaySpend { + let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) + guard let amountUsd = QuickPayLimits.usdValue(sats: amountSats, currency: currency), + let dailyCapUsd = QuickPayLimits.dailyCapUsd( + thresholdUsd: settings.quickpayAmount, + multiplier: multiplier, + currency: currency + ) + else { + throw NSError( + domain: "Payment", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Currency conversion failed"] + ) + } + + let dayKey = QuickPaySpendStore.dayKey() + let reserved = spendStore.tryReserve(amountUsd: amountUsd, dayKey: dayKey, dailyCapUsd: dailyCapUsd) + guard reserved else { + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountUsd)'") + throw NSError( + domain: "Payment", + code: -1, + userInfo: [NSLocalizedDescriptionKey: t("wallet__send_quickpay__daily_limit")] + ) + } + + return ReservedQuickPaySpend(amountUsd: amountUsd, dayKey: dayKey, store: spendStore) + } + private func handlePaymentError(_ error: Error, paymentRequest: String?) { Logger.error("Quickpay payment failed: \(error)") @@ -98,3 +142,13 @@ struct SendQuickpay: View { ))) } } + +private struct ReservedQuickPaySpend { + let amountUsd: Double + let dayKey: String + let store: QuickPaySpendStore + + func release() { + store.release(amountUsd: amountUsd, dayKey: dayKey) + } +} diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift new file mode 100644 index 000000000..0d91ce102 --- /dev/null +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -0,0 +1,123 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class PaymentNavigationHelperTests: XCTestCase { + private let settings = SettingsViewModel.shared + private var originalEnableQuickpay = false + private var originalQuickpayAmount: Double = 0 + private var originalQuickpayDailyLimitMultiplier: Double = 0 + private var originalPinEnabled = false + private var originalRequirePinForPayments = false + private var originalCachedRates: Data? + private var spendDefaults: UserDefaults! + private var spendSuiteName: String! + private var spendStore: QuickPaySpendStore! + + override func setUp() { + super.setUp() + originalEnableQuickpay = settings.enableQuickpay + originalQuickpayAmount = settings.quickpayAmount + originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier + originalPinEnabled = settings.pinEnabled + originalRequirePinForPayments = settings.requirePinForPayments + originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + + spendSuiteName = "PaymentNavigationHelperTests.\(UUID().uuidString)" + spendDefaults = UserDefaults(suiteName: spendSuiteName) + spendStore = QuickPaySpendStore(defaults: spendDefaults) + + settings.enableQuickpay = true + settings.quickpayAmount = 5 + settings.quickpayDailyLimitMultiplier = 5 + guard let encodedRates = try? JSONEncoder().encode([usdRate]) else { + XCTFail("Failed to encode the QuickPay test exchange rate") + return + } + UserDefaults.standard.set(encodedRates, forKey: "cached_fx_rates") + } + + override func tearDown() { + settings.enableQuickpay = originalEnableQuickpay + settings.quickpayAmount = originalQuickpayAmount + settings.quickpayDailyLimitMultiplier = originalQuickpayDailyLimitMultiplier + settings.pinEnabled = originalPinEnabled + settings.requirePinForPayments = originalRequirePinForPayments + + if let originalCachedRates { + UserDefaults.standard.set(originalCachedRates, forKey: "cached_fx_rates") + } else { + UserDefaults.standard.removeObject(forKey: "cached_fx_rates") + } + + spendDefaults.removePersistentDomain(forName: spendSuiteName) + spendDefaults = nil + spendStore = nil + super.tearDown() + } + + func testPaymentPinDoesNotChangeEligibleQuickpayRoute() { + settings.pinEnabled = true + settings.requirePinForPayments = true + + XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) + } + + func testEligibleInvoiceUsesQuickpayUnderDailyCap() { + XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) + } + + func testSkipsQuickpayWhenDailySpendCapIsExceeded() { + // 1000 sats = $1 at the test rate; $25 already spent exceeds the $25 daily cap. + spendStore.record(amountUsd: 25, dayKey: QuickPaySpendStore.dayKey()) + + XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .confirm) + } + + func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() { + // $24 + $1 = $25, which is still within the cap. + spendStore.record(amountUsd: 24, dayKey: QuickPaySpendStore.dayKey()) + + XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) + } + + private func sendRoute(for app: AppViewModel) -> SendRoute? { + PaymentNavigationHelper.appropriateSendRoute( + app: app, + currency: CurrencyViewModel(), + settings: settings, + spendStore: spendStore + ) + } + + private var appWithEligibleInvoice: AppViewModel { + let app = AppViewModel() + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", + paymentHash: Data(), + amountSatoshis: 1000, + timestampSeconds: 0, + expirySeconds: 0, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + return app + } + + private var usdRate: FxRate { + FxRate( + symbol: "BTCUSD", + lastPrice: "100000", + base: "BTC", + baseName: "Bitcoin", + quote: "USD", + quoteName: "US Dollar", + currencySymbol: "$", + currencyFlag: "🇺🇸", + lastUpdatedAt: 0 + ) + } +} diff --git a/changelog.d/next/670.security.md b/changelog.d/next/670.security.md new file mode 100644 index 000000000..3a5339f9f --- /dev/null +++ b/changelog.d/next/670.security.md @@ -0,0 +1 @@ +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead. From ed1ab250bbcc3d5ab75c181b8c140f2b120d658a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:33:51 +0200 Subject: [PATCH 04/27] chore: rename changelog fragment --- changelog.d/next/{670.security.md => 672.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{670.security.md => 672.security.md} (100%) diff --git a/changelog.d/next/670.security.md b/changelog.d/next/672.security.md similarity index 100% rename from changelog.d/next/670.security.md rename to changelog.d/next/672.security.md From dc407b3e967b0800f642835fe06c66a145a05e33 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 17:29:08 +0200 Subject: [PATCH 05/27] fix: port Android QuickPay follow-ups --- .../Localization/en.lproj/Localizable.strings | 2 ++ .../Utilities/PaymentNavigationHelper.swift | 34 +++++++++++++------ Bitkit/Utilities/QuickPayLimits.swift | 10 ++++-- Bitkit/ViewModels/WalletViewModel.swift | 22 ++++++++---- .../Settings/Quickpay/QuickpaySettings.swift | 7 +++- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 24 ++++++------- BitkitTests/QuickPayLimitsTests.swift | 18 ++++++++++ 7 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 BitkitTests/QuickPayLimitsTests.swift diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 49b27ecba..0fa7e5a2d 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -803,7 +803,9 @@ "settings__quickpay__settings__label" = "Quickpay threshold"; "settings__quickpay__settings__daily_label" = "Daily QuickPay limit"; "settings__quickpay__settings__daily_text" = "Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm."; +"settings__quickpay__settings__multiplier_format" = "{multiplier}×"; "settings__quickpay__settings__note" = "* Bitkit QuickPay exclusively supports payments from your Spending Balance."; +"wallet__send_quickpay__currency_conversion" = "Currency conversion failed"; "wallet__send_quickpay__daily_limit" = "Daily QuickPay limit reached"; "settings__security__title" = "Security And Privacy"; "settings__security__swipe_balance_to_hide" = "Swipe balance to hide"; diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 05cf3b0d9..e848043ab 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -23,11 +23,25 @@ struct PaymentNavigationHelper { return false } - let quickpayAmountSats = currency.convert(fiatAmount: settings.quickpayAmount, from: "USD") ?? 0 - guard quickpayAmountSats > 0, amountSats <= quickpayAmountSats else { - return false - } + return isWithinThreshold(amountSats: amountSats, settings: settings, currency: currency) + && isWithinDailyCap(amountSats: amountSats, settings: settings, currency: currency, spendStore: spendStore) + } + private static func isWithinThreshold( + amountSats: UInt64, + settings: SettingsViewModel, + currency: CurrencyViewModel + ) -> Bool { + let quickpayAmountSats = currency.convert(fiatAmount: settings.quickpayAmount, from: QuickPayLimits.usdCurrencyCode) ?? 0 + return quickpayAmountSats > 0 && amountSats <= quickpayAmountSats + } + + private static func isWithinDailyCap( + amountSats: UInt64, + settings: SettingsViewModel, + currency: CurrencyViewModel, + spendStore: QuickPaySpendStore + ) -> Bool { let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) guard let dailyCapUsd = QuickPayLimits.dailyCapUsd( thresholdUsd: settings.quickpayAmount, @@ -39,14 +53,14 @@ struct PaymentNavigationHelper { let dayKey = QuickPaySpendStore.dayKey() let spentUsdToday = spendStore.spentUsd(forDayKey: dayKey) - if spentUsdToday + amountUsd > dailyCapUsd { - Logger.info( - "Skipping QuickPay: daily spend '\(spentUsdToday)' + '\(amountUsd)' exceeds cap '\(dailyCapUsd)'" - ) - return false + if spentUsdToday + amountUsd <= dailyCapUsd { + return true } - return true + Logger.info( + "Skipping QuickPay: daily spend '\(spentUsdToday)' + '\(amountUsd)' exceeds cap '\(dailyCapUsd)'" + ) + return false } /// Centralized method to open the appropriate sheet based on the current state diff --git a/Bitkit/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift index baf806685..85aab7f8a 100644 --- a/Bitkit/Utilities/QuickPayLimits.swift +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -1,11 +1,17 @@ import Foundation enum QuickPayLimits { + static let usdCurrencyCode = "USD" static let thresholdSteps: [Double] = [1, 5, 10, 20, 50] static let dailyMultiplierSteps: [Double] = [1, 3, 5, 10, 50] static let defaultThresholdUsd: Double = 5 static let defaultDailyMultiplier: Double = 5 + static func amountWithFeeSats(amountSats: UInt64, feePaidSats: UInt64) -> UInt64 { + let (total, overflow) = amountSats.addingReportingOverflow(feePaidSats) + return overflow ? UInt64.max : total + } + static func sanitizedMultiplier(_ value: Double) -> Double { dailyMultiplierSteps.contains(value) ? value : defaultDailyMultiplier } @@ -30,7 +36,7 @@ enum QuickPayLimits { multiplier: Double, currency: CurrencyViewModel ) -> Double? { - guard let thresholdSats = currency.convert(fiatAmount: thresholdUsd, from: "USD"), thresholdSats > 0 else { + guard let thresholdSats = currency.convert(fiatAmount: thresholdUsd, from: usdCurrencyCode), thresholdSats > 0 else { return nil } @@ -40,7 +46,7 @@ enum QuickPayLimits { @MainActor static func usdValue(sats: UInt64, currency: CurrencyViewModel) -> Double? { - guard let converted = currency.convert(sats: sats, to: "USD") else { return nil } + guard let converted = currency.convert(sats: sats, to: usdCurrencyCode) else { return nil } return (converted.value as NSDecimalNumber).doubleValue } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index f4739765d..19dedd013 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -780,6 +780,11 @@ class WalletViewModel: ObservableObject { let routeFeeMsat: UInt64? } + struct SettledLightningPayment { + let paymentHash: PaymentHash + let feePaidSats: UInt64 + } + /// Waits for probe results that match one of the returned probe `paymentId`s. /// If any matching probe succeeds, this resolves success immediately. /// If all matching probes fail, this resolves with the final failed probe event. @@ -870,12 +875,14 @@ class WalletViewModel: ObservableObject { sats: UInt64? = nil, timeoutSeconds: TimeInterval = 10, onTimeout: (@MainActor () -> Void)? = nil - ) async throws -> PaymentHash { - try await withThrowingTaskGroup(of: PaymentHash.self) { group in + ) async throws -> SettledLightningPayment { + try await withThrowingTaskGroup(of: SettledLightningPayment.self) { group in group.addTask { try await self.send(bolt11: bolt11, sats: sats) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) - if let onTimeout { await MainActor.run { onTimeout() } } + if let onTimeout { + await MainActor.run { onTimeout() } + } throw PaymentTimeoutError.timedOut } let first = try await group.next()! @@ -888,7 +895,7 @@ class WalletViewModel: ObservableObject { /// A LN payment can throw an error right away, be successful right away, /// or take a while to complete/fail because it's retrying different paths. /// So we need to handle all these cases here. - func send(bolt11: String, sats: UInt64? = nil) async throws -> PaymentHash { + func send(bolt11: String, sats: UInt64? = nil) async throws -> SettledLightningPayment { let hash = try await lightningService.send(bolt11: bolt11, sats: sats) let eventId = String(hash) @@ -896,10 +903,13 @@ class WalletViewModel: ObservableObject { // Add event listener for this specific payment addOnEvent(id: eventId) { event in switch event { - case let .paymentSuccessful(_, paymentHash, _, _): + case let .paymentSuccessful(_, paymentHash, _, feePaidMsat): if paymentHash == hash { self.removeOnEvent(id: eventId) - continuation.resume(returning: paymentHash) + continuation.resume(returning: SettledLightningPayment( + paymentHash: paymentHash, + feePaidSats: (feePaidMsat ?? 0) / 1000 + )) } case .paymentFailed(paymentId: _, let paymentHash, let reason): if paymentHash == hash { diff --git a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift index 261f0150f..a79f9f705 100644 --- a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift +++ b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift @@ -56,7 +56,12 @@ struct QuickpaySettings: View { CustomSlider( value: $settings.quickpayDailyLimitMultiplier, steps: QuickPayLimits.dailyMultiplierSteps, - formatLabel: { "\(Int($0))×" }, + formatLabel: { + t( + "settings__quickpay__settings__multiplier_format", + variables: ["multiplier": String(Int($0))] + ) + }, testIdentifier: "QuickpayDailyLimitSlider" ) } diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 16c2b897e..99e0203ee 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -62,9 +62,7 @@ struct SendQuickpay: View { } guard let bolt11 = bolt11Invoice else { - throw NSError( - domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "No Lightning invoice found"] - ) + throw AppError(message: t("common__error_body"), debugMessage: "No Lightning invoice found") } let amountSats = wallet.sendAmountSats ?? 0 @@ -76,7 +74,7 @@ struct SendQuickpay: View { // Quickpay only triggers for invoices with built-in amounts, so pass sats: nil // to let LDK use the invoice's native millisatoshi precision. do { - try await wallet.sendWithTimeout( + let settled = try await wallet.sendWithTimeout( bolt11: bolt11, sats: nil, onTimeout: { @@ -84,6 +82,10 @@ struct SendQuickpay: View { navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .quickpay, paymentRequest: bolt11)) } ) + wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( + amountSats: amountSats, + feePaidSats: settled.feePaidSats + ) Logger.info("Quickpay payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { @@ -110,10 +112,9 @@ struct SendQuickpay: View { currency: currency ) else { - throw NSError( - domain: "Payment", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Currency conversion failed"] + throw AppError( + message: t("wallet__send_quickpay__currency_conversion"), + debugMessage: "Currency conversion failed" ) } @@ -121,10 +122,9 @@ struct SendQuickpay: View { let reserved = spendStore.tryReserve(amountUsd: amountUsd, dayKey: dayKey, dailyCapUsd: dailyCapUsd) guard reserved else { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountUsd)'") - throw NSError( - domain: "Payment", - code: -1, - userInfo: [NSLocalizedDescriptionKey: t("wallet__send_quickpay__daily_limit")] + throw AppError( + message: t("wallet__send_quickpay__daily_limit"), + debugMessage: "Daily QuickPay limit reached" ) } diff --git a/BitkitTests/QuickPayLimitsTests.swift b/BitkitTests/QuickPayLimitsTests.swift new file mode 100644 index 000000000..f4e15b046 --- /dev/null +++ b/BitkitTests/QuickPayLimitsTests.swift @@ -0,0 +1,18 @@ +@testable import Bitkit +import XCTest + +final class QuickPayLimitsTests: XCTestCase { + func testSanitizedMultiplierFallsBackToDefault() { + XCTAssertEqual(QuickPayLimits.sanitizedMultiplier(5), 5) + XCTAssertEqual(QuickPayLimits.sanitizedMultiplier(7), QuickPayLimits.defaultDailyMultiplier) + } + + func testDailyCapUsdDisplayMultipliesThreshold() { + XCTAssertEqual(QuickPayLimits.dailyCapUsdDisplay(thresholdUsd: 5, multiplier: 5), 25) + } + + func testAmountWithFeeSatsAddsFeeWithoutOverflow() { + XCTAssertEqual(QuickPayLimits.amountWithFeeSats(amountSats: 1000, feePaidSats: 12), 1012) + XCTAssertEqual(QuickPayLimits.amountWithFeeSats(amountSats: UInt64.max, feePaidSats: 1), UInt64.max) + } +} From 9d63bf1844eee2584369991dabadbd977b9a3931 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 20:39:38 +0200 Subject: [PATCH 06/27] fix: QuickPay pending spend and Confirm race --- .../Utilities/PaymentNavigationHelper.swift | 12 ++++ Bitkit/Utilities/QuickPaySpendStore.swift | 55 +++++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 27 ++++++++- .../Wallets/Send/SendPendingScreen.swift | 6 ++ Bitkit/Views/Wallets/Send/SendQuickpay.swift | 21 ++++--- .../PaymentNavigationHelperTests.swift | 7 +++ BitkitTests/QuickPaySpendStoreTests.swift | 30 ++++++++++ 7 files changed, 148 insertions(+), 10 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index e848043ab..b257ae412 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -173,6 +173,18 @@ struct PaymentNavigationHelper { return nil } + static func confirmRouteAfterQuickPayCap(app: AppViewModel) -> SendRoute { + if let lnurlPayData = app.lnurlPayData { + return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount + } + + if let invoice = app.scannedLightningInvoice, invoice.amountSatoshis == 0 { + return .amount + } + + return .confirm + } + static func contactPaymentRoute( app: AppViewModel, currency: CurrencyViewModel, diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 62a6057f5..db682ec69 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -5,10 +5,16 @@ final class QuickPaySpendStore: @unchecked Sendable { static let dayKeyDefaultsKey = "quickPaySpendDayKey" static let spentUsdDefaultsKey = "quickPaySpentUsdToday" + static let pendingReservationsDefaultsKey = "quickPayPendingReservations" private let defaults: UserDefaults private let lock = NSLock() + struct PendingReservation: Codable, Equatable { + let amountUsd: Double + let dayKey: String + } + init(defaults: UserDefaults = .standard) { self.defaults = defaults } @@ -57,6 +63,37 @@ final class QuickPaySpendStore: @unchecked Sendable { lockedWrite(dayKey: dayKey, spentUsd: spent + amountUsd) } + func trackPending(paymentHash: String, amountUsd: Double, dayKey: String) { + lock.lock() + defer { lock.unlock() } + + var pending = lockedPendingReservations() + pending[paymentHash] = PendingReservation(amountUsd: amountUsd, dayKey: dayKey) + lockedWritePending(pending) + } + + func forgetPending(paymentHash: String) { + lock.lock() + defer { lock.unlock() } + + var pending = lockedPendingReservations() + pending.removeValue(forKey: paymentHash) + lockedWritePending(pending) + } + + func releasePending(paymentHash: String) { + lock.lock() + defer { lock.unlock() } + + var pending = lockedPendingReservations() + guard let reservation = pending.removeValue(forKey: paymentHash) else { return } + lockedWritePending(pending) + + guard defaults.string(forKey: Self.dayKeyDefaultsKey) == reservation.dayKey else { return } + let spent = defaults.double(forKey: Self.spentUsdDefaultsKey) + lockedWrite(dayKey: reservation.dayKey, spentUsd: max(spent - reservation.amountUsd, 0)) + } + private func lockedSpentUsd(forDayKey dayKey: String) -> Double { guard defaults.string(forKey: Self.dayKeyDefaultsKey) == dayKey else { return 0 } return defaults.double(forKey: Self.spentUsdDefaultsKey) @@ -66,4 +103,22 @@ final class QuickPaySpendStore: @unchecked Sendable { defaults.set(dayKey, forKey: Self.dayKeyDefaultsKey) defaults.set(spentUsd, forKey: Self.spentUsdDefaultsKey) } + + private func lockedPendingReservations() -> [String: PendingReservation] { + guard let data = defaults.data(forKey: Self.pendingReservationsDefaultsKey), + let decoded = try? JSONDecoder().decode([String: PendingReservation].self, from: data) + else { + return [:] + } + return decoded + } + + private func lockedWritePending(_ pending: [String: PendingReservation]) { + if pending.isEmpty { + defaults.removeObject(forKey: Self.pendingReservationsDefaultsKey) + return + } + + defaults.set(try? JSONEncoder().encode(pending), forKey: Self.pendingReservationsDefaultsKey) + } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 41c385f5e..27d18154b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -9,11 +9,18 @@ struct SendSheetPendingResolution: Equatable { let paymentHash: String let success: Bool let failureReason: PaymentFailureReason? + let feePaidSats: UInt64? - init(paymentHash: String, success: Bool, failureReason: PaymentFailureReason? = nil) { + init( + paymentHash: String, + success: Bool, + failureReason: PaymentFailureReason? = nil, + feePaidSats: UInt64? = nil + ) { self.paymentHash = paymentHash self.success = success self.failureReason = failureReason + self.feePaidSats = feePaidSats } } @@ -1039,11 +1046,19 @@ extension AppViewModel { } case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _): break - case let .paymentSuccessful(paymentId, paymentHash, _, _): + case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash + QuickPaySpendStore.shared.forgetPending(paymentHash: hash) + if paymentHash != hash { + QuickPaySpendStore.shared.forgetPending(paymentHash: paymentHash) + } if pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) - sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: true) + sendSheetPendingResolution = SendSheetPendingResolution( + paymentHash: hash, + success: true, + feePaidSats: (feePaidMsat ?? 0) / 1000 + ) toast( type: .lightning, title: t("wallet__toast_payment_success_title"), @@ -1053,6 +1068,12 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash + if let hash { + QuickPaySpendStore.shared.releasePending(paymentHash: hash) + if let paymentHash, paymentHash != hash { + QuickPaySpendStore.shared.releasePending(paymentHash: paymentHash) + } + } if let hash, pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index d93392b7d..8576d25d3 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -85,6 +85,12 @@ struct SendPendingScreen: View { app.consumeSendSheetPendingResolution(paymentHash: paymentHash) if resolution.success { Task { @MainActor in + if let feePaidSats = resolution.feePaidSats, let amountSats = wallet.sendAmountSats { + wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( + amountSats: amountSats, + feePaidSats: feePaidSats + ) + } await applyPendingContactContextIfNeeded() navigationPath.append(.success(paymentId: paymentHash)) } diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 99e0203ee..d7ca5ef80 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -11,6 +11,7 @@ struct SendQuickpay: View { @Binding var navigationPath: [SendRoute] let routingCacheResetAttempted: Bool var spendStore: QuickPaySpendStore = .shared + @State private var didStartPayment = false var body: some View { VStack { @@ -37,6 +38,8 @@ struct SendQuickpay: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { + guard !didStartPayment else { return } + didStartPayment = true Task { await performPayment() } @@ -66,7 +69,9 @@ struct SendQuickpay: View { } let amountSats = wallet.sendAmountSats ?? 0 - let reservation = try reserveDailySpend(amountSats: amountSats) + guard let reservation = try reserveDailySpend(amountSats: amountSats) else { + return + } let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) @@ -89,7 +94,11 @@ struct SendQuickpay: View { Logger.info("Quickpay payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { - // Pending keeps the reserved spend; onTimeout already navigated. + spendStore.trackPending( + paymentHash: paymentHash, + amountUsd: reservation.amountUsd, + dayKey: reservation.dayKey + ) return } catch { reservation.release() @@ -103,7 +112,7 @@ struct SendQuickpay: View { } } - private func reserveDailySpend(amountSats: UInt64) throws -> ReservedQuickPaySpend { + private func reserveDailySpend(amountSats: UInt64) throws -> ReservedQuickPaySpend? { let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) guard let amountUsd = QuickPayLimits.usdValue(sats: amountSats, currency: currency), let dailyCapUsd = QuickPayLimits.dailyCapUsd( @@ -122,10 +131,8 @@ struct SendQuickpay: View { let reserved = spendStore.tryReserve(amountUsd: amountUsd, dayKey: dayKey, dailyCapUsd: dailyCapUsd) guard reserved else { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountUsd)'") - throw AppError( - message: t("wallet__send_quickpay__daily_limit"), - debugMessage: "Daily QuickPay limit reached" - ) + navigationPath.append(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) + return nil } return ReservedQuickPaySpend(amountUsd: amountUsd, dayKey: dayKey, store: spendStore) diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 0d91ce102..945d58e10 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -82,6 +82,13 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } + func testReserveRaceFallsBackToConfirm() { + XCTAssertEqual( + PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: appWithEligibleInvoice), + .confirm + ) + } + private func sendRoute(for app: AppViewModel) -> SendRoute? { PaymentNavigationHelper.appropriateSendRoute( app: app, diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 841941545..f71d20157 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -74,4 +74,34 @@ final class QuickPaySpendStoreTests: XCTestCase { sut.release(amountUsd: 7.0, dayKey: "2026-08-15") XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-16"), 7.0) } + + func testReleasePendingRollsBackATrackedReservation() { + XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + sut.trackPending(paymentHash: "abc", amountUsd: 5.0, dayKey: "2026-08-15") + + sut.releasePending(paymentHash: "abc") + + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + XCTAssertTrue(sut.tryReserve(amountUsd: 25.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + } + + func testForgetPendingKeepsTheReservation() { + XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + sut.trackPending(paymentHash: "abc", amountUsd: 5.0, dayKey: "2026-08-15") + + sut.forgetPending(paymentHash: "abc") + sut.releasePending(paymentHash: "abc") + + XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 5.0) + } + + func testPendingReservationSurvivesANewStoreInstance() { + XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + sut.trackPending(paymentHash: "abc", amountUsd: 5.0, dayKey: "2026-08-15") + + let reloaded = QuickPaySpendStore(defaults: defaults) + reloaded.releasePending(paymentHash: "abc") + + XCTAssertEqual(reloaded.spentUsd(forDayKey: "2026-08-15"), 0) + } } From f3229771a7ea776d3e496ac360b59cbb757f5d83 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 21:14:38 +0200 Subject: [PATCH 07/27] fix: account QuickPay daily spend in sats --- .../Utilities/PaymentNavigationHelper.swift | 11 ++- Bitkit/Utilities/QuickPayLimits.swift | 14 +-- Bitkit/Utilities/QuickPaySpendStore.swift | 73 +++++++++----- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 24 +++-- .../PaymentNavigationHelperTests.swift | 7 +- BitkitTests/QuickPaySpendStoreTests.swift | 97 ++++++++++++------- 6 files changed, 133 insertions(+), 93 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index b257ae412..de1c7986b 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -43,22 +43,23 @@ struct PaymentNavigationHelper { spendStore: QuickPaySpendStore ) -> Bool { let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) - guard let dailyCapUsd = QuickPayLimits.dailyCapUsd( + guard let dailyCapSats = QuickPayLimits.dailyCapSats( thresholdUsd: settings.quickpayAmount, multiplier: multiplier, currency: currency - ), let amountUsd = QuickPayLimits.usdValue(sats: amountSats, currency: currency) else { + ) else { return false } let dayKey = QuickPaySpendStore.dayKey() - let spentUsdToday = spendStore.spentUsd(forDayKey: dayKey) - if spentUsdToday + amountUsd <= dailyCapUsd { + let spentSatsToday = spendStore.spentSats(forDayKey: dayKey) + let (total, overflow) = spentSatsToday.addingReportingOverflow(amountSats) + if !overflow, total <= dailyCapSats { return true } Logger.info( - "Skipping QuickPay: daily spend '\(spentUsdToday)' + '\(amountUsd)' exceeds cap '\(dailyCapUsd)'" + "Skipping QuickPay: daily spend '\(spentSatsToday)' + '\(amountSats)' exceeds cap '\(dailyCapSats)'" ) return false } diff --git a/Bitkit/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift index 85aab7f8a..513a17e2b 100644 --- a/Bitkit/Utilities/QuickPayLimits.swift +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -31,22 +31,16 @@ enum QuickPayLimits { } @MainActor - static func dailyCapUsd( + static func dailyCapSats( thresholdUsd: Double, multiplier: Double, currency: CurrencyViewModel - ) -> Double? { + ) -> UInt64? { guard let thresholdSats = currency.convert(fiatAmount: thresholdUsd, from: usdCurrencyCode), thresholdSats > 0 else { return nil } - let dailyCapSats = thresholdSats * UInt64(max(multiplier, 1).rounded()) - return usdValue(sats: dailyCapSats, currency: currency) - } - - @MainActor - static func usdValue(sats: UInt64, currency: CurrencyViewModel) -> Double? { - guard let converted = currency.convert(sats: sats, to: usdCurrencyCode) else { return nil } - return (converted.value as NSDecimalNumber).doubleValue + let (dailyCapSats, overflow) = thresholdSats.multipliedReportingOverflow(by: UInt64(max(multiplier, 1).rounded())) + return overflow ? UInt64.max : dailyCapSats } } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index db682ec69..7ee4ae977 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -4,14 +4,14 @@ final class QuickPaySpendStore: @unchecked Sendable { static let shared = QuickPaySpendStore() static let dayKeyDefaultsKey = "quickPaySpendDayKey" - static let spentUsdDefaultsKey = "quickPaySpentUsdToday" + static let spentSatsDefaultsKey = "quickPaySpentSatsToday" static let pendingReservationsDefaultsKey = "quickPayPendingReservations" private let defaults: UserDefaults private let lock = NSLock() struct PendingReservation: Codable, Equatable { - let amountUsd: Double + let amountSats: UInt64 let dayKey: String } @@ -26,53 +26,60 @@ final class QuickPaySpendStore: @unchecked Sendable { return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) } - func spentUsd(forDayKey dayKey: String) -> Double { + func spentSats(forDayKey dayKey: String) -> UInt64 { lock.lock() defer { lock.unlock() } - return lockedSpentUsd(forDayKey: dayKey) + return lockedSpend(forDayKey: dayKey).spentSats } @discardableResult - func tryReserve(amountUsd: Double, dayKey: String, dailyCapUsd: Double) -> Bool { + func tryReserve(amountSats: UInt64, dayKey: String, dailyCapSats: UInt64) -> Bool { lock.lock() defer { lock.unlock() } - let spent = lockedSpentUsd(forDayKey: dayKey) - if spent + amountUsd > dailyCapUsd { + let spend = lockedSpend(forDayKey: dayKey) + let (total, overflow) = spend.spentSats.addingReportingOverflow(amountSats) + if overflow || total > dailyCapSats { return false } - lockedWrite(dayKey: dayKey, spentUsd: spent + amountUsd) + lockedWrite(dayKey: spend.dayKey, spentSats: total) return true } - func release(amountUsd: Double, dayKey: String) { + func release(amountSats: UInt64, dayKey: String) { lock.lock() defer { lock.unlock() } - guard defaults.string(forKey: Self.dayKeyDefaultsKey) == dayKey else { return } - let spent = defaults.double(forKey: Self.spentUsdDefaultsKey) - lockedWrite(dayKey: dayKey, spentUsd: max(spent - amountUsd, 0)) + let spend = lockedSpend(forDayKey: dayKey) + let storedDayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" + guard spend.dayKey == storedDayKey else { return } + lockedWrite(dayKey: spend.dayKey, spentSats: spend.spentSats > amountSats ? spend.spentSats - amountSats : 0) } - func record(amountUsd: Double, dayKey: String) { + func record(amountSats: UInt64, dayKey: String) { lock.lock() defer { lock.unlock() } - let spent = lockedSpentUsd(forDayKey: dayKey) - lockedWrite(dayKey: dayKey, spentUsd: spent + amountUsd) + let spend = lockedSpend(forDayKey: dayKey) + let (total, overflow) = spend.spentSats.addingReportingOverflow(amountSats) + lockedWrite(dayKey: spend.dayKey, spentSats: overflow ? UInt64.max : total) } - func trackPending(paymentHash: String, amountUsd: Double, dayKey: String) { + func trackPending(paymentHash: String, amountSats: UInt64, dayKey: String) { + guard !paymentHash.isEmpty else { return } + lock.lock() defer { lock.unlock() } var pending = lockedPendingReservations() - pending[paymentHash] = PendingReservation(amountUsd: amountUsd, dayKey: dayKey) + pending[paymentHash] = PendingReservation(amountSats: amountSats, dayKey: dayKey) lockedWritePending(pending) } func forgetPending(paymentHash: String) { + guard !paymentHash.isEmpty else { return } + lock.lock() defer { lock.unlock() } @@ -82,6 +89,8 @@ final class QuickPaySpendStore: @unchecked Sendable { } func releasePending(paymentHash: String) { + guard !paymentHash.isEmpty else { return } + lock.lock() defer { lock.unlock() } @@ -89,19 +98,33 @@ final class QuickPaySpendStore: @unchecked Sendable { guard let reservation = pending.removeValue(forKey: paymentHash) else { return } lockedWritePending(pending) - guard defaults.string(forKey: Self.dayKeyDefaultsKey) == reservation.dayKey else { return } - let spent = defaults.double(forKey: Self.spentUsdDefaultsKey) - lockedWrite(dayKey: reservation.dayKey, spentUsd: max(spent - reservation.amountUsd, 0)) + let spend = lockedSpend(forDayKey: reservation.dayKey) + lockedWrite( + dayKey: spend.dayKey, + spentSats: spend.spentSats > reservation.amountSats ? spend.spentSats - reservation.amountSats : 0 + ) + } + + private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentSats: UInt64) { + let storedDayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" + let storedSpend = lockedStoredSpentSats() + + if storedDayKey.isEmpty || dayKey > storedDayKey { + return (dayKey, 0) + } + if dayKey == storedDayKey { + return (dayKey, storedSpend) + } + return (storedDayKey, storedSpend) } - private func lockedSpentUsd(forDayKey dayKey: String) -> Double { - guard defaults.string(forKey: Self.dayKeyDefaultsKey) == dayKey else { return 0 } - return defaults.double(forKey: Self.spentUsdDefaultsKey) + private func lockedStoredSpentSats() -> UInt64 { + UInt64(max(defaults.integer(forKey: Self.spentSatsDefaultsKey), 0)) } - private func lockedWrite(dayKey: String, spentUsd: Double) { + private func lockedWrite(dayKey: String, spentSats: UInt64) { defaults.set(dayKey, forKey: Self.dayKeyDefaultsKey) - defaults.set(spentUsd, forKey: Self.spentUsdDefaultsKey) + defaults.set(Int(clamping: spentSats), forKey: Self.spentSatsDefaultsKey) } private func lockedPendingReservations() -> [String: PendingReservation] { diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index d7ca5ef80..0b27688fc 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -96,7 +96,7 @@ struct SendQuickpay: View { } catch is PaymentTimeoutError { spendStore.trackPending( paymentHash: paymentHash, - amountUsd: reservation.amountUsd, + amountSats: reservation.amountSats, dayKey: reservation.dayKey ) return @@ -114,13 +114,11 @@ struct SendQuickpay: View { private func reserveDailySpend(amountSats: UInt64) throws -> ReservedQuickPaySpend? { let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) - guard let amountUsd = QuickPayLimits.usdValue(sats: amountSats, currency: currency), - let dailyCapUsd = QuickPayLimits.dailyCapUsd( - thresholdUsd: settings.quickpayAmount, - multiplier: multiplier, - currency: currency - ) - else { + guard let dailyCapSats = QuickPayLimits.dailyCapSats( + thresholdUsd: settings.quickpayAmount, + multiplier: multiplier, + currency: currency + ) else { throw AppError( message: t("wallet__send_quickpay__currency_conversion"), debugMessage: "Currency conversion failed" @@ -128,14 +126,14 @@ struct SendQuickpay: View { } let dayKey = QuickPaySpendStore.dayKey() - let reserved = spendStore.tryReserve(amountUsd: amountUsd, dayKey: dayKey, dailyCapUsd: dailyCapUsd) + let reserved = spendStore.tryReserve(amountSats: amountSats, dayKey: dayKey, dailyCapSats: dailyCapSats) guard reserved else { - Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountUsd)'") + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountSats)'") navigationPath.append(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) return nil } - return ReservedQuickPaySpend(amountUsd: amountUsd, dayKey: dayKey, store: spendStore) + return ReservedQuickPaySpend(amountSats: amountSats, dayKey: dayKey, store: spendStore) } private func handlePaymentError(_ error: Error, paymentRequest: String?) { @@ -151,11 +149,11 @@ struct SendQuickpay: View { } private struct ReservedQuickPaySpend { - let amountUsd: Double + let amountSats: UInt64 let dayKey: String let store: QuickPaySpendStore func release() { - store.release(amountUsd: amountUsd, dayKey: dayKey) + store.release(amountSats: amountSats, dayKey: dayKey) } } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 945d58e10..5525b614c 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -69,15 +69,14 @@ final class PaymentNavigationHelperTests: XCTestCase { } func testSkipsQuickpayWhenDailySpendCapIsExceeded() { - // 1000 sats = $1 at the test rate; $25 already spent exceeds the $25 daily cap. - spendStore.record(amountUsd: 25, dayKey: QuickPaySpendStore.dayKey()) + // 1000 sats invoice; $5 × 5 = 25_000 sats at the test rate. + spendStore.record(amountSats: 25000, dayKey: QuickPaySpendStore.dayKey()) XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .confirm) } func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() { - // $24 + $1 = $25, which is still within the cap. - spendStore.record(amountUsd: 24, dayKey: QuickPaySpendStore.dayKey()) + spendStore.record(amountSats: 24000, dayKey: QuickPaySpendStore.dayKey()) XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index f71d20157..9408fd3ce 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -29,79 +29,104 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertEqual(QuickPaySpendStore.dayKey(date: date, timeZone: timeZone), "2026-08-15") } - func testSpentUsdReturnsSpendForMatchingDayKey() { - sut.record(amountUsd: 3.5, dayKey: "2026-08-15") + func testSpentSatsReturnsSpendForMatchingDayKey() { + sut.record(amountSats: 3500, dayKey: "2026-08-15") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 3.5) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 3500) } - func testSpentUsdReturnsZeroForADifferentDayKey() { - sut.record(amountUsd: 12.0, dayKey: "2026-08-14") + func testSpentSatsReturnsZeroForALaterDayKey() { + sut.record(amountSats: 12000, dayKey: "2026-08-14") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) } - func testRecordAccumulatesOnTheSameDayAndResetsOnANewDay() { - sut.record(amountUsd: 2.0, dayKey: "2026-08-15") - sut.record(amountUsd: 1.5, dayKey: "2026-08-15") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 3.5) + func testSpentSatsKeepsSpendOnClockRollback() { + sut.record(amountSats: 12000, dayKey: "2026-08-15") + + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-14"), 12000) + XCTAssertTrue(sut.tryReserve(amountSats: 1000, dayKey: "2026-08-14", dailyCapSats: 20000)) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-14"), 13000) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 13000) + } - sut.record(amountUsd: 4.0, dayKey: "2026-08-16") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-16"), 4.0) - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + func testRecordAccumulatesOnTheSameDayAndResetsOnANewDay() { + sut.record(amountSats: 2000, dayKey: "2026-08-15") + sut.record(amountSats: 1500, dayKey: "2026-08-15") + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 3500) + + sut.record(amountSats: 4000, dayKey: "2026-08-16") + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-16"), 4000) + // An earlier key is treated as a clock rollback, so stored spend is kept. + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 4000) } func testReserveAcceptsSpendUnderTheCapAndRejectsOverIt() { - XCTAssertTrue(sut.tryReserve(amountUsd: 10.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - XCTAssertTrue(sut.tryReserve(amountUsd: 10.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - XCTAssertFalse(sut.tryReserve(amountUsd: 10.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 20.0) + XCTAssertTrue(sut.tryReserve(amountSats: 10000, dayKey: "2026-08-15", dailyCapSats: 25000)) + XCTAssertTrue(sut.tryReserve(amountSats: 10000, dayKey: "2026-08-15", dailyCapSats: 25000)) + XCTAssertFalse(sut.tryReserve(amountSats: 10000, dayKey: "2026-08-15", dailyCapSats: 25000)) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 20000) } func testReserveAllowsSpendThatEqualsTheCap() { - XCTAssertTrue(sut.tryReserve(amountUsd: 25.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 25.0) + XCTAssertTrue(sut.tryReserve(amountSats: 25000, dayKey: "2026-08-15", dailyCapSats: 25000)) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 25000) } func testReleaseRollsBackAReservation() { - XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - sut.release(amountUsd: 5.0, dayKey: "2026-08-15") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) + XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) + sut.release(amountSats: 5000, dayKey: "2026-08-15") + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) + } + + func testReleaseDoesNotChangeSpendForALaterDay() { + sut.record(amountSats: 7000, dayKey: "2026-08-15") + sut.release(amountSats: 7000, dayKey: "2026-08-16") + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 7000) } - func testReleaseDoesNotChangeSpendForADifferentDay() { - sut.record(amountUsd: 7.0, dayKey: "2026-08-16") - sut.release(amountUsd: 7.0, dayKey: "2026-08-15") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-16"), 7.0) + func testReleaseSubtractsOnClockRollback() { + sut.record(amountSats: 7000, dayKey: "2026-08-15") + sut.release(amountSats: 1000, dayKey: "2026-08-14") + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 6000) } func testReleasePendingRollsBackATrackedReservation() { - XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - sut.trackPending(paymentHash: "abc", amountUsd: 5.0, dayKey: "2026-08-15") + XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) + sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-15") + + sut.releasePending(paymentHash: "abc") + + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) + XCTAssertTrue(sut.tryReserve(amountSats: 25000, dayKey: "2026-08-15", dailyCapSats: 25000)) + } + + func testReleasePendingSubtractsOnClockRollback() { + XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) + sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-14") sut.releasePending(paymentHash: "abc") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 0) - XCTAssertTrue(sut.tryReserve(amountUsd: 25.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) } func testForgetPendingKeepsTheReservation() { - XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - sut.trackPending(paymentHash: "abc", amountUsd: 5.0, dayKey: "2026-08-15") + XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) + sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-15") sut.forgetPending(paymentHash: "abc") sut.releasePending(paymentHash: "abc") - XCTAssertEqual(sut.spentUsd(forDayKey: "2026-08-15"), 5.0) + XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 5000) } func testPendingReservationSurvivesANewStoreInstance() { - XCTAssertTrue(sut.tryReserve(amountUsd: 5.0, dayKey: "2026-08-15", dailyCapUsd: 25.0)) - sut.trackPending(paymentHash: "abc", amountUsd: 5.0, dayKey: "2026-08-15") + XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) + sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-15") let reloaded = QuickPaySpendStore(defaults: defaults) reloaded.releasePending(paymentHash: "abc") - XCTAssertEqual(reloaded.spentUsd(forDayKey: "2026-08-15"), 0) + XCTAssertEqual(reloaded.spentSats(forDayKey: "2026-08-15"), 0) } } From 8039526ba7e751b89b724605ccea8fd49ecf9cf1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:20:43 +0200 Subject: [PATCH 08/27] fix: port QuickPay cents spend ledger --- .../Utilities/PaymentNavigationHelper.swift | 64 +++--- Bitkit/Utilities/QuickPayLimits.swift | 35 ++-- Bitkit/Utilities/QuickPaySpendStore.swift | 194 ++++++++++++------ Bitkit/ViewModels/AppViewModel.swift | 34 ++- Bitkit/ViewModels/WalletViewModel.swift | 22 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 6 +- .../Wallets/Send/SendConfirmationView.swift | 6 +- Bitkit/Views/Wallets/Send/SendFailure.swift | 4 + .../Wallets/Send/SendPendingScreen.swift | 2 +- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 66 +++--- Bitkit/Views/Wallets/Send/SendSheet.swift | 34 ++- .../PaymentNavigationHelperTests.swift | 34 ++- BitkitTests/QuickPayLimitsTests.swift | 6 + BitkitTests/QuickPaySpendStoreTests.swift | 168 +++++++++------ 14 files changed, 419 insertions(+), 256 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index de1c7986b..010f78b34 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -15,53 +15,17 @@ struct PaymentNavigationHelper { currency: CurrencyViewModel, spendStore: QuickPaySpendStore = .shared ) -> Bool { - guard settings.enableQuickpay else { - return false - } - guard let amountSats = QuickPayLimits.paymentAmountSats(app: app), amountSats > 0 else { return false } - return isWithinThreshold(amountSats: amountSats, settings: settings, currency: currency) - && isWithinDailyCap(amountSats: amountSats, settings: settings, currency: currency, spendStore: spendStore) - } - - private static func isWithinThreshold( - amountSats: UInt64, - settings: SettingsViewModel, - currency: CurrencyViewModel - ) -> Bool { - let quickpayAmountSats = currency.convert(fiatAmount: settings.quickpayAmount, from: QuickPayLimits.usdCurrencyCode) ?? 0 - return quickpayAmountSats > 0 && amountSats <= quickpayAmountSats - } - - private static func isWithinDailyCap( - amountSats: UInt64, - settings: SettingsViewModel, - currency: CurrencyViewModel, - spendStore: QuickPaySpendStore - ) -> Bool { - let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) - guard let dailyCapSats = QuickPayLimits.dailyCapSats( + return spendStore.canApply( + amountSats: amountSats, + enabled: settings.enableQuickpay, thresholdUsd: settings.quickpayAmount, - multiplier: multiplier, - currency: currency - ) else { - return false - } - - let dayKey = QuickPaySpendStore.dayKey() - let spentSatsToday = spendStore.spentSats(forDayKey: dayKey) - let (total, overflow) = spentSatsToday.addingReportingOverflow(amountSats) - if !overflow, total <= dailyCapSats { - return true - } - - Logger.info( - "Skipping QuickPay: daily spend '\(spentSatsToday)' + '\(amountSats)' exceeds cap '\(dailyCapSats)'" + multiplier: settings.quickpayDailyLimitMultiplier, + rates: .live(currency) ) - return false } /// Centralized method to open the appropriate sheet based on the current state @@ -186,6 +150,24 @@ struct PaymentNavigationHelper { return .confirm } + static func replacingQuickPay( + in path: [SendRoute], + root: SendRoute, + with route: SendRoute + ) -> (root: SendRoute, path: [SendRoute]) { + if root == .quickpay { + return (route, []) + } + + if let index = path.lastIndex(of: .quickpay) { + var nextPath = Array(path.prefix(index)) + nextPath.append(route) + return (root, nextPath) + } + + return (root, path + [route]) + } + static func contactPaymentRoute( app: AppViewModel, currency: CurrencyViewModel, diff --git a/Bitkit/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift index 513a17e2b..694851837 100644 --- a/Bitkit/Utilities/QuickPayLimits.swift +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -17,7 +17,26 @@ enum QuickPayLimits { } static func dailyCapUsdDisplay(thresholdUsd: Double, multiplier: Double) -> Int { - Int(thresholdUsd) * Int(multiplier) + Int(thresholdUsd) * Int(sanitizedMultiplier(multiplier)) + } + + static func thresholdCents(_ thresholdUsd: Double) -> Int64 { + Int64(Int(thresholdUsd)) * 100 + } + + static func capCents(thresholdUsd: Double, multiplier: Double) -> Int64 { + thresholdCents(thresholdUsd) * Int64(Int(sanitizedMultiplier(multiplier))) + } + + static func reserveCents(convertedCents: Int64, thresholdUsd: Double) -> Int64 { + min(convertedCents, thresholdCents(thresholdUsd)) + } + + static func usdCents(from converted: ConvertedAmount) -> Int64 { + var cents = converted.value * 100 + var rounded = Decimal() + NSDecimalRound(&rounded, ¢s, 0, .plain) + return NSDecimalNumber(decimal: rounded).int64Value } @MainActor @@ -29,18 +48,4 @@ enum QuickPayLimits { return app.scannedLightningInvoice?.amountSatoshis } - - @MainActor - static func dailyCapSats( - thresholdUsd: Double, - multiplier: Double, - currency: CurrencyViewModel - ) -> UInt64? { - guard let thresholdSats = currency.convert(fiatAmount: thresholdUsd, from: usdCurrencyCode), thresholdSats > 0 else { - return nil - } - - let (dailyCapSats, overflow) = thresholdSats.multipliedReportingOverflow(by: UInt64(max(multiplier, 1).rounded())) - return overflow ? UInt64.max : dailyCapSats - } } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 7ee4ae977..a7c3580e2 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -1,22 +1,46 @@ import Foundation +struct QuickPaySpendReservation: Codable, Equatable { + let amountCents: Int64 + let dayKey: String +} + +struct QuickPayConversionError: Error {} + +struct QuickPaySpendRates { + let satsToUsdCents: (UInt64) -> Int64? + let usdToSats: (Double) -> UInt64? + + @MainActor + static func live(_ currency: CurrencyViewModel) -> QuickPaySpendRates { + QuickPaySpendRates( + satsToUsdCents: { sats in + guard let converted = currency.convert(sats: sats, to: QuickPayLimits.usdCurrencyCode) else { + return nil + } + return QuickPayLimits.usdCents(from: converted) + }, + usdToSats: { usd in + currency.convert(fiatAmount: usd, from: QuickPayLimits.usdCurrencyCode) + } + ) + } +} + final class QuickPaySpendStore: @unchecked Sendable { static let shared = QuickPaySpendStore() static let dayKeyDefaultsKey = "quickPaySpendDayKey" - static let spentSatsDefaultsKey = "quickPaySpentSatsToday" - static let pendingReservationsDefaultsKey = "quickPayPendingReservations" + static let spentCentsDefaultsKey = "quickPaySpentCentsToday" + static let reservationsDefaultsKey = "quickPayReservations" private let defaults: UserDefaults private let lock = NSLock() + private let dayKeyProvider: () -> String - struct PendingReservation: Codable, Equatable { - let amountSats: UInt64 - let dayKey: String - } - - init(defaults: UserDefaults = .standard) { + init(defaults: UserDefaults = .standard, dayKey: @escaping () -> String = { QuickPaySpendStore.dayKey() }) { self.defaults = defaults + dayKeyProvider = dayKey } static func dayKey(date: Date = Date(), timeZone: TimeZone = .current) -> String { @@ -26,122 +50,164 @@ final class QuickPaySpendStore: @unchecked Sendable { return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) } - func spentSats(forDayKey dayKey: String) -> UInt64 { + func spentCentsToday() -> Int64 { lock.lock() defer { lock.unlock() } - return lockedSpend(forDayKey: dayKey).spentSats + return lockedSpend(forDayKey: dayKeyProvider()).spentCents } - @discardableResult - func tryReserve(amountSats: UInt64, dayKey: String, dailyCapSats: UInt64) -> Bool { - lock.lock() - defer { lock.unlock() } - - let spend = lockedSpend(forDayKey: dayKey) - let (total, overflow) = spend.spentSats.addingReportingOverflow(amountSats) - if overflow || total > dailyCapSats { + func canApply( + amountSats: UInt64, + enabled: Bool, + thresholdUsd: Double, + multiplier: Double, + rates: QuickPaySpendRates + ) -> Bool { + guard enabled, amountSats > 0 else { return false } + guard let thresholdSats = rates.usdToSats(thresholdUsd), thresholdSats > 0 else { return false } + if amountSats > thresholdSats { return false } + guard let convertedCents = rates.satsToUsdCents(amountSats) else { return false } - lockedWrite(dayKey: spend.dayKey, spentSats: total) - return true - } + let reserveCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd) + let capCents = QuickPayLimits.capCents(thresholdUsd: thresholdUsd, multiplier: multiplier) - func release(amountSats: UInt64, dayKey: String) { lock.lock() defer { lock.unlock() } + let spentCents = lockedSpend(forDayKey: dayKeyProvider()).spentCents + let (total, overflow) = spentCents.addingReportingOverflow(reserveCents) + if !overflow, total <= capCents { + return true + } - let spend = lockedSpend(forDayKey: dayKey) - let storedDayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" - guard spend.dayKey == storedDayKey else { return } - lockedWrite(dayKey: spend.dayKey, spentSats: spend.spentSats > amountSats ? spend.spentSats - amountSats : 0) + Logger.info( + "Skipping QuickPay: daily spend '\(spentCents)' + '\(reserveCents)' exceeds cap '\(capCents)'" + ) + return false } - func record(amountSats: UInt64, dayKey: String) { + func tryReserve( + amountSats: UInt64, + thresholdUsd: Double, + multiplier: Double, + rates: QuickPaySpendRates + ) throws -> QuickPaySpendReservation? { + guard let convertedCents = rates.satsToUsdCents(amountSats) else { + throw QuickPayConversionError() + } + + let amountCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd) + let capCents = QuickPayLimits.capCents(thresholdUsd: thresholdUsd, multiplier: multiplier) + lock.lock() defer { lock.unlock() } - let spend = lockedSpend(forDayKey: dayKey) - let (total, overflow) = spend.spentSats.addingReportingOverflow(amountSats) - lockedWrite(dayKey: spend.dayKey, spentSats: overflow ? UInt64.max : total) + let spend = lockedSpend(forDayKey: dayKeyProvider()) + let (total, overflow) = spend.spentCents.addingReportingOverflow(amountCents) + if overflow || total > capCents { + return nil + } + + lockedWriteSpend(dayKey: spend.dayKey, spentCents: total) + return QuickPaySpendReservation(amountCents: amountCents, dayKey: spend.dayKey) } - func trackPending(paymentHash: String, amountSats: UInt64, dayKey: String) { + func remember(paymentHash: String, reservation: QuickPaySpendReservation) { guard !paymentHash.isEmpty else { return } lock.lock() defer { lock.unlock() } - var pending = lockedPendingReservations() - pending[paymentHash] = PendingReservation(amountSats: amountSats, dayKey: dayKey) - lockedWritePending(pending) + var reservations = lockedReservations() + reservations[paymentHash] = reservation + lockedWriteReservations(reservations) } - func forgetPending(paymentHash: String) { - guard !paymentHash.isEmpty else { return } + func reservation(paymentHash: String) -> QuickPaySpendReservation? { + guard !paymentHash.isEmpty else { return nil } lock.lock() defer { lock.unlock() } - - var pending = lockedPendingReservations() - pending.removeValue(forKey: paymentHash) - lockedWritePending(pending) + return lockedReservations()[paymentHash] } - func releasePending(paymentHash: String) { + func release(paymentHash: String) { guard !paymentHash.isEmpty else { return } lock.lock() defer { lock.unlock() } - var pending = lockedPendingReservations() - guard let reservation = pending.removeValue(forKey: paymentHash) else { return } - lockedWritePending(pending) + var reservations = lockedReservations() + guard let reservation = reservations.removeValue(forKey: paymentHash) else { return } + lockedWriteReservations(reservations) let spend = lockedSpend(forDayKey: reservation.dayKey) - lockedWrite( - dayKey: spend.dayKey, - spentSats: spend.spentSats > reservation.amountSats ? spend.spentSats - reservation.amountSats : 0 - ) + guard reservation.dayKey == spend.dayKey else { return } + lockedWriteSpend(dayKey: spend.dayKey, spentCents: max(spend.spentCents - reservation.amountCents, 0)) + } + + func releaseUnbound(_ reservation: QuickPaySpendReservation) { + lock.lock() + defer { lock.unlock() } + + let storedDayKey = lockedStoredDayKey() + guard reservation.dayKey == storedDayKey else { return } + lockedWriteSpend(dayKey: storedDayKey, spentCents: max(lockedStoredSpentCents() - reservation.amountCents, 0)) } - private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentSats: UInt64) { - let storedDayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" - let storedSpend = lockedStoredSpentSats() + func clear(paymentHash: String) { + guard !paymentHash.isEmpty else { return } + + lock.lock() + defer { lock.unlock() } + + var reservations = lockedReservations() + guard reservations.removeValue(forKey: paymentHash) != nil else { return } + lockedWriteReservations(reservations) + } + + private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentCents: Int64) { + let storedDayKey = lockedStoredDayKey() + let storedCents = lockedStoredSpentCents() if storedDayKey.isEmpty || dayKey > storedDayKey { return (dayKey, 0) } if dayKey == storedDayKey { - return (dayKey, storedSpend) + return (dayKey, storedCents) } - return (storedDayKey, storedSpend) + return (storedDayKey, storedCents) + } + + private func lockedStoredDayKey() -> String { + defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" } - private func lockedStoredSpentSats() -> UInt64 { - UInt64(max(defaults.integer(forKey: Self.spentSatsDefaultsKey), 0)) + private func lockedStoredSpentCents() -> Int64 { + Int64(max(defaults.integer(forKey: Self.spentCentsDefaultsKey), 0)) } - private func lockedWrite(dayKey: String, spentSats: UInt64) { + private func lockedWriteSpend(dayKey: String, spentCents: Int64) { defaults.set(dayKey, forKey: Self.dayKeyDefaultsKey) - defaults.set(Int(clamping: spentSats), forKey: Self.spentSatsDefaultsKey) + defaults.set(Int(clamping: spentCents), forKey: Self.spentCentsDefaultsKey) } - private func lockedPendingReservations() -> [String: PendingReservation] { - guard let data = defaults.data(forKey: Self.pendingReservationsDefaultsKey), - let decoded = try? JSONDecoder().decode([String: PendingReservation].self, from: data) + private func lockedReservations() -> [String: QuickPaySpendReservation] { + guard let data = defaults.data(forKey: Self.reservationsDefaultsKey), + let decoded = try? JSONDecoder().decode([String: QuickPaySpendReservation].self, from: data) else { return [:] } return decoded } - private func lockedWritePending(_ pending: [String: PendingReservation]) { - if pending.isEmpty { - defaults.removeObject(forKey: Self.pendingReservationsDefaultsKey) + private func lockedWriteReservations(_ reservations: [String: QuickPaySpendReservation]) { + if reservations.isEmpty { + defaults.removeObject(forKey: Self.reservationsDefaultsKey) return } - defaults.set(try? JSONEncoder().encode(pending), forKey: Self.pendingReservationsDefaultsKey) + defaults.set(try? JSONEncoder().encode(reservations), forKey: Self.reservationsDefaultsKey) } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 27d18154b..ac59ded6b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -99,6 +99,7 @@ class AppViewModel: ObservableObject { /// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate. private var pendingPaymentHashes: Set = [] private var pendingContactPaymentContexts: [String: ContactPaymentContext] = [:] + private(set) var isQuickPayActive = false /// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate. /// Consumed by SendPendingScreen via consumeSendSheetPendingResolution. @@ -396,6 +397,18 @@ extension AppViewModel { guard sendSheetPendingResolution?.paymentHash == hash else { return } sendSheetPendingResolution = nil } + + func beginQuickPay() -> Bool { + if isQuickPayActive { + return false + } + isQuickPayActive = true + return true + } + + func resetQuickPay() { + isQuickPayActive = false + } } // MARK: Scanning/pasting handling @@ -808,6 +821,7 @@ extension AppViewModel { if !preservingContactPaymentContext { contactPaymentContext = nil } + resetQuickPay() } } @@ -1048,16 +1062,18 @@ extension AppViewModel { break case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash - QuickPaySpendStore.shared.forgetPending(paymentHash: hash) - if paymentHash != hash { - QuickPaySpendStore.shared.forgetPending(paymentHash: paymentHash) - } if pendingPaymentHashes.contains(hash) { + let isQuickPay = QuickPaySpendStore.shared.reservation(paymentHash: hash) != nil + || paymentHash != hash && QuickPaySpendStore.shared.reservation(paymentHash: paymentHash) != nil + QuickPaySpendStore.shared.clear(paymentHash: hash) + if paymentHash != hash { + QuickPaySpendStore.shared.clear(paymentHash: paymentHash) + } pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution( paymentHash: hash, success: true, - feePaidSats: (feePaidMsat ?? 0) / 1000 + feePaidSats: isQuickPay ? (feePaidMsat ?? 0) / 1000 : nil ) toast( type: .lightning, @@ -1068,13 +1084,11 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash - if let hash { - QuickPaySpendStore.shared.releasePending(paymentHash: hash) + if let hash, pendingPaymentHashes.contains(hash) { + QuickPaySpendStore.shared.release(paymentHash: hash) if let paymentHash, paymentHash != hash { - QuickPaySpendStore.shared.releasePending(paymentHash: paymentHash) + QuickPaySpendStore.shared.release(paymentHash: paymentHash) } - } - if let hash, pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) toast( diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 3095d8872..5e6ec61cd 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -874,14 +874,17 @@ class WalletViewModel: ObservableObject { bolt11: String, sats: UInt64? = nil, timeoutSeconds: TimeInterval = 10, - onTimeout: (@MainActor () -> Void)? = nil + afterListening: (@MainActor (String) -> Void)? = nil, + onTimeout: (@MainActor (String) -> Void)? = nil ) async throws -> SettledLightningPayment { - try await withThrowingTaskGroup(of: SettledLightningPayment.self) { group in - group.addTask { try await self.send(bolt11: bolt11, sats: sats) } + let hash = try await lightningService.send(bolt11: bolt11, sats: sats) + let paymentHash = String(hash) + return try await withThrowingTaskGroup(of: SettledLightningPayment.self) { group in + group.addTask { try await self.watchSend(hash: paymentHash, afterListening: afterListening) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) if let onTimeout { - await MainActor.run { onTimeout() } + await MainActor.run { onTimeout(paymentHash) } } throw PaymentTimeoutError.timedOut } @@ -897,10 +900,16 @@ class WalletViewModel: ObservableObject { /// So we need to handle all these cases here. func send(bolt11: String, sats: UInt64? = nil) async throws -> SettledLightningPayment { let hash = try await lightningService.send(bolt11: bolt11, sats: sats) - let eventId = String(hash) + return try await watchSend(hash: String(hash)) + } + + private func watchSend( + hash: String, + afterListening: (@MainActor (String) -> Void)? = nil + ) async throws -> SettledLightningPayment { + let eventId = hash return try await withCheckedThrowingContinuation { continuation in - // Add event listener for this specific payment addOnEvent(id: eventId) { event in switch event { case let .paymentSuccessful(_, paymentHash, _, feePaidMsat): @@ -921,6 +930,7 @@ class WalletViewModel: ObservableObject { } } + afterListening?(hash) syncState() } } diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index ec144e3b7..e6c938cfa 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -223,9 +223,9 @@ struct LnurlPayConfirm: View { try await wallet.sendWithTimeout( bolt11: bolt11, sats: nil, - onTimeout: { - app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) - navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .lnurlPayConfirm, paymentRequest: bolt11)) + onTimeout: { timedOutHash in + app.addPendingPaymentHash(timedOutHash, contactPublicKey: contactPublicKey) + navigationPath.append(.pending(paymentHash: timedOutHash, retryRoute: .lnurlPayConfirm, paymentRequest: bolt11)) } ) app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index d88ab040e..17590e18e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -518,9 +518,9 @@ struct SendConfirmationView: View { try await wallet.sendWithTimeout( bolt11: invoice.bolt11, sats: paymentSats, - onTimeout: { - app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) - navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .confirm, paymentRequest: invoice.bolt11)) + onTimeout: { timedOutHash in + app.addPendingPaymentHash(timedOutHash, contactPublicKey: contactPublicKey) + navigationPath.append(.pending(paymentHash: timedOutHash, retryRoute: .confirm, paymentRequest: invoice.bolt11)) } ) await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) diff --git a/Bitkit/Views/Wallets/Send/SendFailure.swift b/Bitkit/Views/Wallets/Send/SendFailure.swift index 829dd5b17..d4859a2bc 100644 --- a/Bitkit/Views/Wallets/Send/SendFailure.swift +++ b/Bitkit/Views/Wallets/Send/SendFailure.swift @@ -4,6 +4,10 @@ import SwiftUI func sendFailureMessage(for error: Error) -> String { let fallbackMessage = t("wallet__payment_failed_description") + if error is QuickPayConversionError { + return t("wallet__send_quickpay__currency_conversion") + } + if let reason = (error as? AppError)?.paymentFailureReason { return PaymentFailureReason.userMessage(for: reason) } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 8576d25d3..84ffd44b8 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -85,7 +85,7 @@ struct SendPendingScreen: View { app.consumeSendSheetPendingResolution(paymentHash: paymentHash) if resolution.success { Task { @MainActor in - if let feePaidSats = resolution.feePaidSats, let amountSats = wallet.sendAmountSats { + if retryRoute == .quickpay, let feePaidSats = resolution.feePaidSats, let amountSats = wallet.sendAmountSats { wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( amountSats: amountSats, feePaidSats: feePaidSats diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 0b27688fc..34f4a6c7a 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -11,6 +11,7 @@ struct SendQuickpay: View { @Binding var navigationPath: [SendRoute] let routingCacheResetAttempted: Bool var spendStore: QuickPaySpendStore = .shared + var replaceQuickPay: (SendRoute) -> Void @State private var didStartPayment = false var body: some View { @@ -47,12 +48,12 @@ struct SendQuickpay: View { } private func performPayment() async { + guard app.beginQuickPay() else { return } + var bolt11Invoice: String? do { - // Handle LNURL Pay if let lnurlPayData = app.lnurlPayData { - // Set the amount in sats for the success screen wallet.sendAmountSats = lnurlPayData.minSendableSat bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( @@ -73,20 +74,22 @@ struct SendQuickpay: View { return } - let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) - let paymentHash = String(describing: parsedInvoice.paymentHash()) - - // Quickpay only triggers for invoices with built-in amounts, so pass sats: nil - // to let LDK use the invoice's native millisatoshi precision. + var submittedHash = "" do { let settled = try await wallet.sendWithTimeout( bolt11: bolt11, sats: nil, - onTimeout: { + afterListening: { paymentHash in + submittedHash = paymentHash + spendStore.remember(paymentHash: paymentHash, reservation: reservation) + }, + onTimeout: { paymentHash in app.addPendingPaymentHash(paymentHash) navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .quickpay, paymentRequest: bolt11)) } ) + let paymentHash = String(settled.paymentHash) + spendStore.clear(paymentHash: paymentHash) wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( amountSats: amountSats, feePaidSats: settled.feePaidSats @@ -94,46 +97,37 @@ struct SendQuickpay: View { Logger.info("Quickpay payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { - spendStore.trackPending( - paymentHash: paymentHash, - amountSats: reservation.amountSats, - dayKey: reservation.dayKey - ) return } catch { - reservation.release() + if submittedHash.isEmpty { + spendStore.releaseUnbound(reservation) + } else { + spendStore.release(paymentHash: submittedHash) + } throw error } } catch is PaymentTimeoutError { - // onTimeout callback already navigated to .pending; suppress throw return } catch { handlePaymentError(error, paymentRequest: bolt11Invoice) } } - private func reserveDailySpend(amountSats: UInt64) throws -> ReservedQuickPaySpend? { - let multiplier = QuickPayLimits.sanitizedMultiplier(settings.quickpayDailyLimitMultiplier) - guard let dailyCapSats = QuickPayLimits.dailyCapSats( + private func reserveDailySpend(amountSats: UInt64) throws -> QuickPaySpendReservation? { + let reserved = try spendStore.tryReserve( + amountSats: amountSats, thresholdUsd: settings.quickpayAmount, - multiplier: multiplier, - currency: currency - ) else { - throw AppError( - message: t("wallet__send_quickpay__currency_conversion"), - debugMessage: "Currency conversion failed" - ) - } + multiplier: settings.quickpayDailyLimitMultiplier, + rates: .live(currency) + ) - let dayKey = QuickPaySpendStore.dayKey() - let reserved = spendStore.tryReserve(amountSats: amountSats, dayKey: dayKey, dailyCapSats: dailyCapSats) - guard reserved else { + guard let reserved else { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountSats)'") - navigationPath.append(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) + replaceQuickPay(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) return nil } - return ReservedQuickPaySpend(amountSats: amountSats, dayKey: dayKey, store: spendStore) + return reserved } private func handlePaymentError(_ error: Error, paymentRequest: String?) { @@ -147,13 +141,3 @@ struct SendQuickpay: View { ))) } } - -private struct ReservedQuickPaySpend { - let amountSats: UInt64 - let dayKey: String - let store: QuickPaySpendStore - - func release() { - store.release(amountSats: amountSats, dayKey: dayKey) - } -} diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 696c02dde..0bc8dbcc5 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -87,11 +87,16 @@ struct SendSheet: View { let config: SendSheetItem @State private var navigationPath: [SendRoute] = [] + @State private var rootOverride: SendRoute? @State private var hasValidatedAfterSync = false @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @State private var pinCheckContinuations: [CheckedContinuation] = [] + private var currentRoot: SendRoute { + rootOverride ?? config.initialRoute + } + /// How long the sync overlay may wait for channels to become usable before falling back private static let syncTimeoutSeconds: TimeInterval = 20 @@ -145,7 +150,7 @@ struct SendSheet: View { .transition(.opacity) } else { NavigationStack(path: $navigationPath) { - viewForRoute(config.initialRoute) + viewForRoute(currentRoot) .navigationDestination(for: SendRoute.self) { route in viewForRoute(route) } @@ -187,6 +192,7 @@ struct SendSheet: View { } .onDisappear { app.contactPaymentContext = nil + app.resetQuickPay() } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. @@ -466,7 +472,11 @@ struct SendSheet: View { case .tag: SendTagScreen(navigationPath: $navigationPath) case .quickpay: - SendQuickpay(navigationPath: $navigationPath, routingCacheResetAttempted: routingCacheResetAttempted) + SendQuickpay( + navigationPath: $navigationPath, + routingCacheResetAttempted: routingCacheResetAttempted, + replaceQuickPay: replaceQuickPay(with:) + ) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) case let .pending(paymentHash, retryRoute, paymentRequest): @@ -525,9 +535,27 @@ struct SendSheet: View { } } + private func replaceQuickPay(with route: SendRoute) { + app.resetQuickPay() + let next = PaymentNavigationHelper.replacingQuickPay(in: navigationPath, root: currentRoot, with: route) + rootOverride = next.root == config.initialRoute ? nil : next.root + navigationPath = next.path + } + private func resetNavigationForRetry(_ retryRoute: SendRetryRoute) { let route = retryRoute.sendRoute - navigationPath = route == config.initialRoute ? [] : [route] + if retryRoute == .quickpay { + app.resetQuickPay() + } + if route == config.initialRoute || currentRoot == route { + if route == config.initialRoute { + rootOverride = nil + } + navigationPath = [] + return + } + + navigationPath = [route] } } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 5525b614c..e19ceaeee 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -68,15 +68,21 @@ final class PaymentNavigationHelperTests: XCTestCase { XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } - func testSkipsQuickpayWhenDailySpendCapIsExceeded() { - // 1000 sats invoice; $5 × 5 = 25_000 sats at the test rate. - spendStore.record(amountSats: 25000, dayKey: QuickPaySpendStore.dayKey()) + func testSkipsQuickpayWhenDailySpendCapIsExceeded() throws { + let rates = QuickPaySpendRates.live(CurrencyViewModel()) + for _ in 0 ..< 5 { + XCTAssertNotNil(try spendStore.tryReserve(amountSats: 5000, thresholdUsd: 5, multiplier: 5, rates: rates)) + } XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .confirm) } - func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() { - spendStore.record(amountSats: 24000, dayKey: QuickPaySpendStore.dayKey()) + func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() throws { + let rates = QuickPaySpendRates.live(CurrencyViewModel()) + for _ in 0 ..< 4 { + XCTAssertNotNil(try spendStore.tryReserve(amountSats: 5000, thresholdUsd: 5, multiplier: 5, rates: rates)) + } + XCTAssertNotNil(try spendStore.tryReserve(amountSats: 4000, thresholdUsd: 5, multiplier: 5, rates: rates)) XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } @@ -88,6 +94,24 @@ final class PaymentNavigationHelperTests: XCTestCase { ) } + func testReplacingQuickPayRootLeavesConfirmWithoutABackTarget() { + let next = PaymentNavigationHelper.replacingQuickPay(in: [], root: .quickpay, with: .confirm) + + XCTAssertEqual(next.root, .confirm) + XCTAssertTrue(next.path.isEmpty) + } + + func testReplacingQuickPayOnThePathKeepsTheExistingRoot() { + let next = PaymentNavigationHelper.replacingQuickPay( + in: [.amount, .quickpay], + root: .options, + with: .confirm + ) + + XCTAssertEqual(next.root, .options) + XCTAssertEqual(next.path, [.amount, .confirm]) + } + private func sendRoute(for app: AppViewModel) -> SendRoute? { PaymentNavigationHelper.appropriateSendRoute( app: app, diff --git a/BitkitTests/QuickPayLimitsTests.swift b/BitkitTests/QuickPayLimitsTests.swift index f4e15b046..2158a4f93 100644 --- a/BitkitTests/QuickPayLimitsTests.swift +++ b/BitkitTests/QuickPayLimitsTests.swift @@ -11,6 +11,12 @@ final class QuickPayLimitsTests: XCTestCase { XCTAssertEqual(QuickPayLimits.dailyCapUsdDisplay(thresholdUsd: 5, multiplier: 5), 25) } + func testCapCentsUsesIntegerUsdAndMultiplier() { + XCTAssertEqual(QuickPayLimits.capCents(thresholdUsd: 5, multiplier: 5), 2500) + XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 700, thresholdUsd: 5), 500) + XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 200, thresholdUsd: 5), 200) + } + func testAmountWithFeeSatsAddsFeeWithoutOverflow() { XCTAssertEqual(QuickPayLimits.amountWithFeeSats(amountSats: 1000, feePaidSats: 12), 1012) XCTAssertEqual(QuickPayLimits.amountWithFeeSats(amountSats: UInt64.max, feePaidSats: 1), UInt64.max) diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 9408fd3ce..b2360cb96 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -4,13 +4,19 @@ import XCTest final class QuickPaySpendStoreTests: XCTestCase { private var defaults: UserDefaults! private var suiteName: String! + private var currentDay = "2026-08-15" private var sut: QuickPaySpendStore! + private let rates = QuickPaySpendRates( + satsToUsdCents: { sats in Int64(sats) / 2 }, + usdToSats: { usd in UInt64(usd * 200) } + ) override func setUp() { super.setUp() suiteName = "QuickPaySpendStoreTests.\(UUID().uuidString)" defaults = UserDefaults(suiteName: suiteName) - sut = QuickPaySpendStore(defaults: defaults) + currentDay = "2026-08-15" + sut = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) } override func tearDown() { @@ -29,104 +35,138 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertEqual(QuickPaySpendStore.dayKey(date: date, timeZone: timeZone), "2026-08-15") } - func testSpentSatsReturnsSpendForMatchingDayKey() { - sut.record(amountSats: 3500, dayKey: "2026-08-15") + func testSpentCentsTodayReturnsSpendForMatchingDay() throws { + XCTAssertNotNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 3500) + XCTAssertEqual(sut.spentCentsToday(), 250) } - func testSpentSatsReturnsZeroForALaterDayKey() { - sut.record(amountSats: 12000, dayKey: "2026-08-14") + func testSpentCentsTodayReturnsZeroForALaterDay() throws { + XCTAssertNotNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) + currentDay = "2026-08-16" - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) + XCTAssertEqual(sut.spentCentsToday(), 0) } - func testSpentSatsKeepsSpendOnClockRollback() { - sut.record(amountSats: 12000, dayKey: "2026-08-15") + func testSpentCentsTodayKeepsSpendOnClockRollback() throws { + XCTAssertNotNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) + currentDay = "2026-08-14" - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-14"), 12000) - XCTAssertTrue(sut.tryReserve(amountSats: 1000, dayKey: "2026-08-14", dailyCapSats: 20000)) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-14"), 13000) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 13000) + XCTAssertEqual(sut.spentCentsToday(), 250) + XCTAssertNotNil(try sut.tryReserve(amountSats: 200, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.spentCentsToday(), 350) + currentDay = "2026-08-15" + XCTAssertEqual(sut.spentCentsToday(), 350) } - func testRecordAccumulatesOnTheSameDayAndResetsOnANewDay() { - sut.record(amountSats: 2000, dayKey: "2026-08-15") - sut.record(amountSats: 1500, dayKey: "2026-08-15") - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 3500) + func testTryReserveAccumulatesOnTheSameDayAndResetsOnANewDay() throws { + XCTAssertNotNil(try sut.tryReserve(amountSats: 400, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try sut.tryReserve(amountSats: 300, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.spentCentsToday(), 350) - sut.record(amountSats: 4000, dayKey: "2026-08-16") - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-16"), 4000) - // An earlier key is treated as a clock rollback, so stored spend is kept. - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 4000) + currentDay = "2026-08-16" + XCTAssertNotNil(try sut.tryReserve(amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.spentCentsToday(), 400) } - func testReserveAcceptsSpendUnderTheCapAndRejectsOverIt() { - XCTAssertTrue(sut.tryReserve(amountSats: 10000, dayKey: "2026-08-15", dailyCapSats: 25000)) - XCTAssertTrue(sut.tryReserve(amountSats: 10000, dayKey: "2026-08-15", dailyCapSats: 25000)) - XCTAssertFalse(sut.tryReserve(amountSats: 10000, dayKey: "2026-08-15", dailyCapSats: 25000)) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 20000) + func testTryReserveReservesUnderTheCapAndRejectsOverIt() throws { + for _ in 0 ..< 5 { + XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + } + XCTAssertNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.spentCentsToday(), 2500) } - func testReserveAllowsSpendThatEqualsTheCap() { - XCTAssertTrue(sut.tryReserve(amountSats: 25000, dayKey: "2026-08-15", dailyCapSats: 25000)) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 25000) + func testReleaseUnboundRollsBackAReservation() throws { + let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + + sut.releaseUnbound(reserved) + + XCTAssertEqual(sut.spentCentsToday(), 0) } - func testReleaseRollsBackAReservation() { - XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) - sut.release(amountSats: 5000, dayKey: "2026-08-15") - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) + func testReleaseUnboundOnAPriorDayDoesNotDecrementTheNewDay() throws { + let old = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + currentDay = "2026-08-16" + XCTAssertNotNil(try sut.tryReserve(amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) + + sut.releaseUnbound(old) + + XCTAssertEqual(sut.spentCentsToday(), 400) } - func testReleaseDoesNotChangeSpendForALaterDay() { - sut.record(amountSats: 7000, dayKey: "2026-08-15") - sut.release(amountSats: 7000, dayKey: "2026-08-16") - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 7000) + func testReleaseFreesPendingSpendByPaymentHash() throws { + let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + sut.remember(paymentHash: "abc", reservation: reserved) + + sut.release(paymentHash: "abc") + + XCTAssertEqual(sut.spentCentsToday(), 0) + XCTAssertNil(sut.reservation(paymentHash: "abc")) } - func testReleaseSubtractsOnClockRollback() { - sut.record(amountSats: 7000, dayKey: "2026-08-15") - sut.release(amountSats: 1000, dayKey: "2026-08-14") - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 6000) + func testClearKeepsSpendAfterSuccess() throws { + let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + sut.remember(paymentHash: "abc", reservation: reserved) + + sut.clear(paymentHash: "abc") + + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNil(sut.reservation(paymentHash: "abc")) } - func testReleasePendingRollsBackATrackedReservation() { - XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) - sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-15") + func testReleaseOnAPriorDayDoesNotDecrementTheNewDay() throws { + let old = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + sut.remember(paymentHash: "old", reservation: old) + currentDay = "2026-08-16" + XCTAssertNotNil(try sut.tryReserve(amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.releasePending(paymentHash: "abc") + sut.release(paymentHash: "old") - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) - XCTAssertTrue(sut.tryReserve(amountSats: 25000, dayKey: "2026-08-15", dailyCapSats: 25000)) + XCTAssertEqual(sut.spentCentsToday(), 400) + XCTAssertNil(sut.reservation(paymentHash: "old")) } - func testReleasePendingSubtractsOnClockRollback() { - XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) - sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-14") + func testCanApplyIsTrueUnderThresholdAndCap() { + XCTAssertTrue( + sut.canApply(amountSats: 500, enabled: true, thresholdUsd: 5, multiplier: 5, rates: rates) + ) + } - sut.releasePending(paymentHash: "abc") + func testCanApplyIsFalseWhenDailyCapWouldBeExceeded() throws { + XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 1, rates: rates)) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 0) + XCTAssertFalse( + sut.canApply(amountSats: 1000, enabled: true, thresholdUsd: 5, multiplier: 1, rates: rates) + ) } - func testForgetPendingKeepsTheReservation() { - XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) - sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-15") + func testCanApplyIsFalseWhenDisabled() { + XCTAssertFalse( + sut.canApply(amountSats: 500, enabled: false, thresholdUsd: 5, multiplier: 5, rates: rates) + ) + } - sut.forgetPending(paymentHash: "abc") - sut.releasePending(paymentHash: "abc") + func testTryReserveFailsWithConversionErrorWhenRatesAreUnavailable() { + let missingRates = QuickPaySpendRates( + satsToUsdCents: { _ in nil }, + usdToSats: { _ in nil } + ) - XCTAssertEqual(sut.spentSats(forDayKey: "2026-08-15"), 5000) + XCTAssertThrowsError( + try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: missingRates) + ) { error in + XCTAssertTrue(error is QuickPayConversionError) + } } - func testPendingReservationSurvivesANewStoreInstance() { - XCTAssertTrue(sut.tryReserve(amountSats: 5000, dayKey: "2026-08-15", dailyCapSats: 25000)) - sut.trackPending(paymentHash: "abc", amountSats: 5000, dayKey: "2026-08-15") + func testReservationSurvivesANewStoreInstance() throws { + let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + sut.remember(paymentHash: "abc", reservation: reserved) - let reloaded = QuickPaySpendStore(defaults: defaults) - reloaded.releasePending(paymentHash: "abc") + let reloaded = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) + reloaded.release(paymentHash: "abc") - XCTAssertEqual(reloaded.spentSats(forDayKey: "2026-08-15"), 0) + XCTAssertEqual(reloaded.spentCentsToday(), 0) } } From ba1b26f5b5dc7fd7df38923861360e2771dbe9c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 00:50:11 +0200 Subject: [PATCH 09/27] fix: remount QuickPay on Try Again --- Bitkit/Views/Wallets/Send/SendSheet.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 0bc8dbcc5..722b8cd83 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -88,6 +88,7 @@ struct SendSheet: View { @State private var navigationPath: [SendRoute] = [] @State private var rootOverride: SendRoute? + @State private var quickPaySession = 0 @State private var hasValidatedAfterSync = false @State private var routingCacheResetAttempted = false @State private var syncTimedOut = false @@ -477,6 +478,7 @@ struct SendSheet: View { routingCacheResetAttempted: routingCacheResetAttempted, replaceQuickPay: replaceQuickPay(with:) ) + .id(quickPaySession) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) case let .pending(paymentHash, retryRoute, paymentRequest): @@ -546,6 +548,7 @@ struct SendSheet: View { let route = retryRoute.sendRoute if retryRoute == .quickpay { app.resetQuickPay() + quickPaySession += 1 } if route == config.initialRoute || currentRoot == route { if route == config.initialRoute { From afb62943413b5285e10ff53091192c830c787ccf Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 18:08:25 +0200 Subject: [PATCH 10/27] fix: restore QuickPay spend from metadata backup --- Bitkit/Models/BackupPayloads.swift | 15 ++++++++++++++- Bitkit/Models/SettingsBackupConfig.swift | 3 +++ Bitkit/Utilities/QuickPaySpendStore.swift | 13 +++++++++++++ Bitkit/ViewModels/SettingsViewModel.swift | 13 +++++++++++-- BitkitTests/QuickPaySpendStoreTests.swift | 16 ++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index e3f38fef1..cf84be56d 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -49,6 +49,9 @@ struct AppCacheData: Codable { let highBalanceIgnoreTimestamp: TimeInterval let dismissedSuggestions: [String] let lastUsedTags: [String] + let quickPaySpendDayKey: String + let quickPaySpentCentsToday: Int64 + let quickPayReservations: [String: QuickPaySpendReservation] init( hasSeenContactsIntro: Bool, @@ -66,7 +69,10 @@ struct AppCacheData: Codable { highBalanceIgnoreCount: Int, highBalanceIgnoreTimestamp: TimeInterval, dismissedSuggestions: [String], - lastUsedTags: [String] + lastUsedTags: [String], + quickPaySpendDayKey: String = "", + quickPaySpentCentsToday: Int64 = 0, + quickPayReservations: [String: QuickPaySpendReservation] = [:] ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro @@ -84,6 +90,9 @@ struct AppCacheData: Codable { self.highBalanceIgnoreTimestamp = highBalanceIgnoreTimestamp self.dismissedSuggestions = dismissedSuggestions self.lastUsedTags = lastUsedTags + self.quickPaySpendDayKey = quickPaySpendDayKey + self.quickPaySpentCentsToday = quickPaySpentCentsToday + self.quickPayReservations = quickPayReservations } init(from decoder: Decoder) throws { @@ -104,6 +113,9 @@ struct AppCacheData: Codable { highBalanceIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) dismissedSuggestions = try c.decode([String].self, forKey: .dismissedSuggestions) lastUsedTags = try c.decode([String].self, forKey: .lastUsedTags) + quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) ?? "" + quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) ?? 0 + quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) ?? [:] } private enum CodingKeys: String, CodingKey { @@ -112,6 +124,7 @@ struct AppCacheData: Codable { case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp case dismissedSuggestions, lastUsedTags + case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations } } diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 1afb04728..fe8a8769c 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -32,6 +32,9 @@ enum SettingsBackupConfig { "highBalanceIgnoreTimestamp", "dismissedSuggestions", "lastUsedTags", + "quickPaySpendDayKey", + "quickPaySpentCentsToday", + "quickPayReservations", ] static let settingsKeyTypes: [String: SettingKeyType] = [ diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index a7c3580e2..7c0250e38 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -167,6 +167,19 @@ final class QuickPaySpendStore: @unchecked Sendable { lockedWriteReservations(reservations) } + func backupSnapshot() -> (dayKey: String, spentCents: Int64, reservations: [String: QuickPaySpendReservation]) { + lock.lock() + defer { lock.unlock() } + return (lockedStoredDayKey(), lockedStoredSpentCents(), lockedReservations()) + } + + func restoreFromBackup(dayKey: String, spentCents: Int64, reservations: [String: QuickPaySpendReservation]) { + lock.lock() + defer { lock.unlock() } + lockedWriteSpend(dayKey: dayKey, spentCents: max(spentCents, 0)) + lockedWriteReservations(reservations) + } + private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentCents: Int64) { let storedDayKey = lockedStoredDayKey() let storedCents = lockedStoredSpentCents() diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index c91a07aca..5407301f0 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -867,7 +867,8 @@ class SettingsViewModel: NSObject, ObservableObject { /// Gets the current app cache data for backup func getAppCacheData() -> AppCacheData { - AppCacheData( + let spend = QuickPaySpendStore.shared.backupSnapshot() + return AppCacheData( hasSeenContactsIntro: defaults.bool(forKey: "hasSeenContactsIntro"), hasSeenProfileIntro: defaults.bool(forKey: "hasSeenProfileIntro"), hasSeenNotificationsIntro: defaults.bool(forKey: "hasSeenNotificationsIntro"), @@ -883,7 +884,10 @@ class SettingsViewModel: NSObject, ObservableObject { highBalanceIgnoreCount: defaults.integer(forKey: "highBalanceIgnoreCount"), highBalanceIgnoreTimestamp: defaults.double(forKey: "highBalanceIgnoreTimestamp"), dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [], - lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [] + lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], + quickPaySpendDayKey: spend.dayKey, + quickPaySpentCentsToday: spend.spentCents, + quickPayReservations: spend.reservations ) } @@ -905,5 +909,10 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") defaults.set(cache.dismissedSuggestions, forKey: "dismissedSuggestions") defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") + QuickPaySpendStore.shared.restoreFromBackup( + dayKey: cache.quickPaySpendDayKey, + spentCents: cache.quickPaySpentCentsToday, + reservations: cache.quickPayReservations + ) } } diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index b2360cb96..6e4ccd513 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -127,6 +127,22 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertNil(sut.reservation(paymentHash: "old")) } + func testBackupSnapshotRoundTripsSpendAndReservations() throws { + let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + sut.remember(paymentHash: "abc", reservation: reserved) + + let snapshot = sut.backupSnapshot() + let restored = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) + restored.restoreFromBackup( + dayKey: snapshot.dayKey, + spentCents: snapshot.spentCents, + reservations: snapshot.reservations + ) + + XCTAssertEqual(restored.spentCentsToday(), 500) + XCTAssertEqual(restored.reservation(paymentHash: "abc"), reserved) + } + func testCanApplyIsTrueUnderThresholdAndCap() { XCTAssertTrue( sut.canApply(amountSats: 500, enabled: true, thresholdUsd: 5, multiplier: 5, rates: rates) From 73e48f3b98a56af55517f54304aad07c5b198a06 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 20:03:05 +0200 Subject: [PATCH 11/27] fix: count sub-cent quickpay as 1 cent --- Bitkit/Utilities/QuickPayLimits.swift | 8 ++++++-- Bitkit/Utilities/QuickPaySpendStore.swift | 4 ++-- BitkitTests/QuickPayLimitsTests.swift | 6 ++++-- BitkitTests/QuickPaySpendStoreTests.swift | 22 ++++++++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Bitkit/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift index 694851837..92e77801e 100644 --- a/Bitkit/Utilities/QuickPayLimits.swift +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -28,8 +28,12 @@ enum QuickPayLimits { thresholdCents(thresholdUsd) * Int64(Int(sanitizedMultiplier(multiplier))) } - static func reserveCents(convertedCents: Int64, thresholdUsd: Double) -> Int64 { - min(convertedCents, thresholdCents(thresholdUsd)) + static func reserveCents(convertedCents: Int64, thresholdUsd: Double, amountSats: UInt64) -> Int64 { + let clamped = min(convertedCents, thresholdCents(thresholdUsd)) + if amountSats == 0 { + return clamped + } + return max(clamped, 1) } static func usdCents(from converted: ConvertedAmount) -> Int64 { diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 7c0250e38..66b800cdb 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -70,7 +70,7 @@ final class QuickPaySpendStore: @unchecked Sendable { } guard let convertedCents = rates.satsToUsdCents(amountSats) else { return false } - let reserveCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd) + let reserveCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd, amountSats: amountSats) let capCents = QuickPayLimits.capCents(thresholdUsd: thresholdUsd, multiplier: multiplier) lock.lock() @@ -97,7 +97,7 @@ final class QuickPaySpendStore: @unchecked Sendable { throw QuickPayConversionError() } - let amountCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd) + let amountCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd, amountSats: amountSats) let capCents = QuickPayLimits.capCents(thresholdUsd: thresholdUsd, multiplier: multiplier) lock.lock() diff --git a/BitkitTests/QuickPayLimitsTests.swift b/BitkitTests/QuickPayLimitsTests.swift index 2158a4f93..50e52f9c5 100644 --- a/BitkitTests/QuickPayLimitsTests.swift +++ b/BitkitTests/QuickPayLimitsTests.swift @@ -13,8 +13,10 @@ final class QuickPayLimitsTests: XCTestCase { func testCapCentsUsesIntegerUsdAndMultiplier() { XCTAssertEqual(QuickPayLimits.capCents(thresholdUsd: 5, multiplier: 5), 2500) - XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 700, thresholdUsd: 5), 500) - XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 200, thresholdUsd: 5), 200) + XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 700, thresholdUsd: 5, amountSats: 1), 500) + XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 200, thresholdUsd: 5, amountSats: 1), 200) + XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 0, thresholdUsd: 5, amountSats: 7), 1) + XCTAssertEqual(QuickPayLimits.reserveCents(convertedCents: 0, thresholdUsd: 5, amountSats: 0), 0) } func testAmountWithFeeSatsAddsFeeWithoutOverflow() { diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 6e4ccd513..0d260703e 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -10,6 +10,10 @@ final class QuickPaySpendStoreTests: XCTestCase { satsToUsdCents: { sats in Int64(sats) / 2 }, usdToSats: { usd in UInt64(usd * 200) } ) + private let dustRates = QuickPaySpendRates( + satsToUsdCents: { _ in 0 }, + usdToSats: { usd in UInt64(usd * 200) } + ) override func setUp() { super.setUp() @@ -149,6 +153,24 @@ final class QuickPaySpendStoreTests: XCTestCase { ) } + func testZeroCentConversionAtFullCapDoesNotQuickPay() throws { + for _ in 0 ..< 5 { + XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + } + + XCTAssertFalse( + sut.canApply(amountSats: 7, enabled: true, thresholdUsd: 5, multiplier: 5, rates: dustRates) + ) + XCTAssertNil(try sut.tryReserve(amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) + } + + func testZeroCentConversionReservesOneCent() throws { + let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) + + XCTAssertEqual(reserved.amountCents, 1) + XCTAssertEqual(sut.spentCentsToday(), 1) + } + func testCanApplyIsFalseWhenDailyCapWouldBeExceeded() throws { XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 1, rates: rates)) From 224a729f6012605fd757557505556f93520c0260 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 20:09:44 +0200 Subject: [PATCH 12/27] fix: restore quickpay cap across backup and pending events --- Bitkit/Models/BackupPayloads.swift | 30 ++++++------- Bitkit/Models/SettingsBackupConfig.swift | 1 + Bitkit/ViewModels/AppViewModel.swift | 16 ++++--- .../Wallets/Send/SendPendingScreen.swift | 45 ++++++++++--------- BitkitTests/AddressTypeSettingsTests.swift | 11 +++++ BitkitTests/QuickPaySpendStoreTests.swift | 26 +++++++++++ 6 files changed, 87 insertions(+), 42 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index cf84be56d..846b9ce3e 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -97,22 +97,22 @@ struct AppCacheData: Codable { init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - hasSeenContactsIntro = try c.decode(Bool.self, forKey: .hasSeenContactsIntro) - hasSeenProfileIntro = try c.decode(Bool.self, forKey: .hasSeenProfileIntro) - hasSeenNotificationsIntro = try c.decode(Bool.self, forKey: .hasSeenNotificationsIntro) - hasSeenQuickpayIntro = try c.decode(Bool.self, forKey: .hasSeenQuickpayIntro) - hasSeenShopIntro = try c.decode(Bool.self, forKey: .hasSeenShopIntro) - hasSeenTransferIntro = try c.decode(Bool.self, forKey: .hasSeenTransferIntro) - hasSeenTransferToSpendingIntro = try c.decode(Bool.self, forKey: .hasSeenTransferToSpendingIntro) - hasSeenTransferToSavingsIntro = try c.decode(Bool.self, forKey: .hasSeenTransferToSavingsIntro) - hasSeenWidgetsIntro = try c.decode(Bool.self, forKey: .hasSeenWidgetsIntro) + hasSeenContactsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenContactsIntro) ?? false + hasSeenProfileIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenProfileIntro) ?? false + hasSeenNotificationsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenNotificationsIntro) ?? false + hasSeenQuickpayIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenQuickpayIntro) ?? false + hasSeenShopIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenShopIntro) ?? false + hasSeenTransferIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferIntro) ?? false + hasSeenTransferToSpendingIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSpendingIntro) ?? false + hasSeenTransferToSavingsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSavingsIntro) ?? false + hasSeenWidgetsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenWidgetsIntro) ?? false hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) ?? false - appUpdateIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) - backupIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .backupIgnoreTimestamp) - highBalanceIgnoreCount = try c.decode(Int.self, forKey: .highBalanceIgnoreCount) - highBalanceIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) - dismissedSuggestions = try c.decode([String].self, forKey: .dismissedSuggestions) - lastUsedTags = try c.decode([String].self, forKey: .lastUsedTags) + appUpdateIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) ?? 0 + backupIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .backupIgnoreTimestamp) ?? 0 + highBalanceIgnoreCount = try c.decodeIfPresent(Int.self, forKey: .highBalanceIgnoreCount) ?? 0 + highBalanceIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) ?? 0 + dismissedSuggestions = try c.decodeIfPresent([String].self, forKey: .dismissedSuggestions) ?? [] + lastUsedTags = try c.decodeIfPresent([String].self, forKey: .lastUsedTags) ?? [] quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) ?? "" quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) ?? 0 quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) ?? [:] diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index fe8a8769c..9299fcf77 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -69,6 +69,7 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", + "quickpayAmount": "quickPayAmount", "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", // Note: PIN settings are intentionally NOT backed up for security diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index ac59ded6b..0636f9c6b 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1062,13 +1062,13 @@ extension AppViewModel { break case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash + let isQuickPay = QuickPaySpendStore.shared.reservation(paymentHash: hash) != nil + || paymentHash != hash && QuickPaySpendStore.shared.reservation(paymentHash: paymentHash) != nil + QuickPaySpendStore.shared.clear(paymentHash: hash) + if paymentHash != hash { + QuickPaySpendStore.shared.clear(paymentHash: paymentHash) + } if pendingPaymentHashes.contains(hash) { - let isQuickPay = QuickPaySpendStore.shared.reservation(paymentHash: hash) != nil - || paymentHash != hash && QuickPaySpendStore.shared.reservation(paymentHash: paymentHash) != nil - QuickPaySpendStore.shared.clear(paymentHash: hash) - if paymentHash != hash { - QuickPaySpendStore.shared.clear(paymentHash: paymentHash) - } pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution( paymentHash: hash, @@ -1084,11 +1084,13 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash - if let hash, pendingPaymentHashes.contains(hash) { + if let hash { QuickPaySpendStore.shared.release(paymentHash: hash) if let paymentHash, paymentHash != hash { QuickPaySpendStore.shared.release(paymentHash: paymentHash) } + } + if let hash, pendingPaymentHashes.contains(hash) { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) toast( diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 84ffd44b8..e68f54ea4 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -79,30 +79,35 @@ struct SendPendingScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .task { await searchForActivity() + applyPendingResolutionIfNeeded(app.sendSheetPendingResolution) } .onChange(of: app.sendSheetPendingResolution) { _, resolution in - guard let resolution, resolution.paymentHash == paymentHash else { return } - app.consumeSendSheetPendingResolution(paymentHash: paymentHash) - if resolution.success { - Task { @MainActor in - if retryRoute == .quickpay, let feePaidSats = resolution.feePaidSats, let amountSats = wallet.sendAmountSats { - wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( - amountSats: amountSats, - feePaidSats: feePaidSats - ) - } - await applyPendingContactContextIfNeeded() - navigationPath.append(.success(paymentId: paymentHash)) + applyPendingResolutionIfNeeded(resolution) + } + } + + private func applyPendingResolutionIfNeeded(_ resolution: SendSheetPendingResolution?) { + guard let resolution, resolution.paymentHash == paymentHash else { return } + app.consumeSendSheetPendingResolution(paymentHash: paymentHash) + if resolution.success { + Task { @MainActor in + if retryRoute == .quickpay, let feePaidSats = resolution.feePaidSats, let amountSats = wallet.sendAmountSats { + wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( + amountSats: amountSats, + feePaidSats: feePaidSats + ) } - } else { - app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) - navigationPath.append(.failure(SendFailureContext( - error: AppError(paymentFailureReason: resolution.failureReason), - retryRoute: retryRoute, - routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: paymentRequest - ))) + await applyPendingContactContextIfNeeded() + navigationPath.append(.success(paymentId: paymentHash)) } + } else { + app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) + navigationPath.append(.failure(SendFailureContext( + error: AppError(paymentFailureReason: resolution.failureReason), + retryRoute: retryRoute, + routingCacheResetAttempted: routingCacheResetAttempted, + paymentRequest: paymentRequest + ))) } } diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index f58e2757b..e6a71bac6 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -254,6 +254,7 @@ final class AddressTypeSettingsTests: XCTestCase { settings.hideBalance = true settings.enableQuickpay = true settings.quickpayDailyLimitMultiplier = 10 + settings.quickpayAmount = 1 UserDefaults.standard.synchronize() let backupDict = settings.getSettingsDictionary() @@ -277,8 +278,12 @@ final class AddressTypeSettingsTests: XCTestCase { "enableQuickpay should survive full backup→reset→restore cycle") XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 10, "quickpayDailyLimitMultiplier should survive full backup→reset→restore cycle") + XCTAssertEqual(settings.quickpayAmount, 1, + "quickpayAmount should survive full backup→reset→restore cycle") XCTAssertEqual(backupDict["quickPayDailyLimitMultiplier"] as? Int, 10) XCTAssertNil(backupDict["quickpayDailyLimitMultiplier"]) + XCTAssertEqual(backupDict["quickPayAmount"] as? Int, 1) + XCTAssertNil(backupDict["quickpayAmount"]) } func testRestoresDailyLimitMultiplierFromAndroidKey() { @@ -287,6 +292,12 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 3) } + func testRestoresQuickpayAmountFromAndroidKey() { + settings.restoreSettingsDictionary(["quickPayAmount": 1]) + + XCTAssertEqual(settings.quickpayAmount, 1) + } + func testInvalidDailyLimitMultiplierFallsBackToDefault() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 0d260703e..08e196db5 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -131,6 +131,32 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertNil(sut.reservation(paymentHash: "old")) } + func testAppCacheDataDecodesAndroidShapedSpendFields() throws { + let reservation = QuickPaySpendReservation(amountCents: 500, dayKey: "2026-08-15") + let json = """ + { + "quickPaySpendDayKey": "2026-08-15", + "quickPaySpentCentsToday": 500, + "quickPayReservations": { + "abc": { "amountCents": 500, "dayKey": "2026-08-15" } + } + } + """.data(using: .utf8)! + + let cache = try JSONDecoder().decode(AppCacheData.self, from: json) + sut.restoreFromBackup( + dayKey: cache.quickPaySpendDayKey, + spentCents: cache.quickPaySpentCentsToday, + reservations: cache.quickPayReservations + ) + + XCTAssertEqual(cache.quickPaySpendDayKey, "2026-08-15") + XCTAssertEqual(cache.quickPaySpentCentsToday, 500) + XCTAssertEqual(cache.quickPayReservations["abc"], reservation) + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertEqual(sut.reservation(paymentHash: "abc"), reservation) + } + func testBackupSnapshotRoundTripsSpendAndReservations() throws { let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) sut.remember(paymentHash: "abc", reservation: reserved) From b3bb5d4a6a5074b060ff978174c76dcb71d9c8c3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 21:11:59 +0200 Subject: [PATCH 13/27] fix: restore android quickpay backup payloads --- Bitkit/Models/BackupPayloads.swift | 64 +++++++++---------- Bitkit/Models/SettingsBackupConfig.swift | 2 + Bitkit/Utilities/QuickPaySpendStore.swift | 3 + Bitkit/ViewModels/SettingsViewModel.swift | 64 ++++++++++++++----- .../Wallets/Send/SendPendingScreen.swift | 2 +- BitkitTests/AddressTypeSettingsTests.swift | 15 +++++ BitkitTests/QuickPaySpendStoreTests.swift | 26 +++++++- 7 files changed, 126 insertions(+), 50 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 846b9ce3e..8182534d5 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -33,22 +33,22 @@ struct PubkySessionBackupV1: Codable, Equatable { } struct AppCacheData: Codable { - let hasSeenContactsIntro: Bool - let hasSeenProfileIntro: Bool - let hasSeenNotificationsIntro: Bool - let hasSeenQuickpayIntro: Bool - let hasSeenShopIntro: Bool - let hasSeenTransferIntro: Bool - let hasSeenTransferToSpendingIntro: Bool - let hasSeenTransferToSavingsIntro: Bool - let hasSeenWidgetsIntro: Bool - let hasDismissedWidgetsOnboardingHint: Bool - let appUpdateIgnoreTimestamp: TimeInterval - let backupIgnoreTimestamp: TimeInterval - let highBalanceIgnoreCount: Int - let highBalanceIgnoreTimestamp: TimeInterval - let dismissedSuggestions: [String] - let lastUsedTags: [String] + let hasSeenContactsIntro: Bool? + let hasSeenProfileIntro: Bool? + let hasSeenNotificationsIntro: Bool? + let hasSeenQuickpayIntro: Bool? + let hasSeenShopIntro: Bool? + let hasSeenTransferIntro: Bool? + let hasSeenTransferToSpendingIntro: Bool? + let hasSeenTransferToSavingsIntro: Bool? + let hasSeenWidgetsIntro: Bool? + let hasDismissedWidgetsOnboardingHint: Bool? + let appUpdateIgnoreTimestamp: TimeInterval? + let backupIgnoreTimestamp: TimeInterval? + let highBalanceIgnoreCount: Int? + let highBalanceIgnoreTimestamp: TimeInterval? + let dismissedSuggestions: [String]? + let lastUsedTags: [String]? let quickPaySpendDayKey: String let quickPaySpentCentsToday: Int64 let quickPayReservations: [String: QuickPaySpendReservation] @@ -97,22 +97,22 @@ struct AppCacheData: Codable { init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - hasSeenContactsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenContactsIntro) ?? false - hasSeenProfileIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenProfileIntro) ?? false - hasSeenNotificationsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenNotificationsIntro) ?? false - hasSeenQuickpayIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenQuickpayIntro) ?? false - hasSeenShopIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenShopIntro) ?? false - hasSeenTransferIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferIntro) ?? false - hasSeenTransferToSpendingIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSpendingIntro) ?? false - hasSeenTransferToSavingsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSavingsIntro) ?? false - hasSeenWidgetsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenWidgetsIntro) ?? false - hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) ?? false - appUpdateIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) ?? 0 - backupIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .backupIgnoreTimestamp) ?? 0 - highBalanceIgnoreCount = try c.decodeIfPresent(Int.self, forKey: .highBalanceIgnoreCount) ?? 0 - highBalanceIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) ?? 0 - dismissedSuggestions = try c.decodeIfPresent([String].self, forKey: .dismissedSuggestions) ?? [] - lastUsedTags = try c.decodeIfPresent([String].self, forKey: .lastUsedTags) ?? [] + hasSeenContactsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenContactsIntro) + hasSeenProfileIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenProfileIntro) + hasSeenNotificationsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenNotificationsIntro) + hasSeenQuickpayIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenQuickpayIntro) + hasSeenShopIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenShopIntro) + hasSeenTransferIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferIntro) + hasSeenTransferToSpendingIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSpendingIntro) + hasSeenTransferToSavingsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSavingsIntro) + hasSeenWidgetsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenWidgetsIntro) + hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) + appUpdateIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) + backupIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .backupIgnoreTimestamp) + highBalanceIgnoreCount = try c.decodeIfPresent(Int.self, forKey: .highBalanceIgnoreCount) + highBalanceIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) + dismissedSuggestions = try c.decodeIfPresent([String].self, forKey: .dismissedSuggestions) + lastUsedTags = try c.decodeIfPresent([String].self, forKey: .lastUsedTags) quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) ?? "" quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) ?? 0 quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) ?? [:] diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 9299fcf77..e1086ff5f 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -47,6 +47,7 @@ enum SettingsBackupConfig { "selectedAddressType": .string(optional: true), "addressTypesToMonitor": .string(optional: true), "enableQuickpay": .bool, + "hasSeenQuickpayIntro": .bool, "showWidgets": .bool, "swipeBalanceToHide": .bool, "hideBalance": .bool, @@ -69,6 +70,7 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", + "hasSeenQuickpayIntro": "quickPayIntroSeen", "quickpayAmount": "quickPayAmount", "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 66b800cdb..bac582353 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -93,6 +93,9 @@ final class QuickPaySpendStore: @unchecked Sendable { multiplier: Double, rates: QuickPaySpendRates ) throws -> QuickPaySpendReservation? { + guard let thresholdSats = rates.usdToSats(thresholdUsd), thresholdSats > 0, amountSats <= thresholdSats else { + return nil + } guard let convertedCents = rates.satsToUsdCents(amountSats) else { throw QuickPayConversionError() } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 5407301f0..b9e1dbde1 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -893,22 +893,54 @@ class SettingsViewModel: NSObject, ObservableObject { /// Restores app cache data from backup func restoreAppCacheData(_ cache: AppCacheData) { - defaults.set(cache.hasSeenContactsIntro, forKey: "hasSeenContactsIntro") - defaults.set(cache.hasSeenProfileIntro, forKey: "hasSeenProfileIntro") - defaults.set(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") - defaults.set(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") - defaults.set(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") - defaults.set(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") - defaults.set(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") - defaults.set(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") - defaults.set(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") - defaults.set(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") - defaults.set(cache.appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") - defaults.set(cache.backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") - defaults.set(cache.highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") - defaults.set(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") - defaults.set(cache.dismissedSuggestions, forKey: "dismissedSuggestions") - defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") + if let hasSeenContactsIntro = cache.hasSeenContactsIntro { + defaults.set(hasSeenContactsIntro, forKey: "hasSeenContactsIntro") + } + if let hasSeenProfileIntro = cache.hasSeenProfileIntro { + defaults.set(hasSeenProfileIntro, forKey: "hasSeenProfileIntro") + } + if let hasSeenNotificationsIntro = cache.hasSeenNotificationsIntro { + defaults.set(hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") + } + if let hasSeenQuickpayIntro = cache.hasSeenQuickpayIntro { + defaults.set(hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") + } + if let hasSeenShopIntro = cache.hasSeenShopIntro { + defaults.set(hasSeenShopIntro, forKey: "hasSeenShopIntro") + } + if let hasSeenTransferIntro = cache.hasSeenTransferIntro { + defaults.set(hasSeenTransferIntro, forKey: "hasSeenTransferIntro") + } + if let hasSeenTransferToSpendingIntro = cache.hasSeenTransferToSpendingIntro { + defaults.set(hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") + } + if let hasSeenTransferToSavingsIntro = cache.hasSeenTransferToSavingsIntro { + defaults.set(hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") + } + if let hasSeenWidgetsIntro = cache.hasSeenWidgetsIntro { + defaults.set(hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") + } + if let hasDismissedWidgetsOnboardingHint = cache.hasDismissedWidgetsOnboardingHint { + defaults.set(hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") + } + if let appUpdateIgnoreTimestamp = cache.appUpdateIgnoreTimestamp { + defaults.set(appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") + } + if let backupIgnoreTimestamp = cache.backupIgnoreTimestamp { + defaults.set(backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") + } + if let highBalanceIgnoreCount = cache.highBalanceIgnoreCount { + defaults.set(highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") + } + if let highBalanceIgnoreTimestamp = cache.highBalanceIgnoreTimestamp { + defaults.set(highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") + } + if let dismissedSuggestions = cache.dismissedSuggestions { + defaults.set(dismissedSuggestions, forKey: "dismissedSuggestions") + } + if let lastUsedTags = cache.lastUsedTags { + defaults.set(lastUsedTags, forKey: "lastUsedTags") + } QuickPaySpendStore.shared.restoreFromBackup( dayKey: cache.quickPaySpendDayKey, spentCents: cache.quickPaySpentCentsToday, diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index e68f54ea4..cc6d516dd 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -78,8 +78,8 @@ struct SendPendingScreen: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { - await searchForActivity() applyPendingResolutionIfNeeded(app.sendSheetPendingResolution) + await searchForActivity() } .onChange(of: app.sendSheetPendingResolution) { _, resolution in applyPendingResolutionIfNeeded(resolution) diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index e6a71bac6..70f2f0aad 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -16,6 +16,7 @@ final class AddressTypeSettingsTests: XCTestCase { override func tearDown() { settings.resetToDefaults() + UserDefaults.standard.removeObject(forKey: "hasSeenQuickpayIntro") super.tearDown() } @@ -298,6 +299,20 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(settings.quickpayAmount, 1) } + func testRestoresQuickPaySettingsFromAndroidSettingsDataKeys() { + settings.restoreSettingsDictionary([ + "isQuickPayEnabled": true, + "quickPayAmount": 1, + "quickPayDailyLimitMultiplier": 50, + "quickPayIntroSeen": true, + ]) + + XCTAssertEqual(settings.enableQuickpay, true) + XCTAssertEqual(settings.quickpayAmount, 1) + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 50) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) + } + func testInvalidDailyLimitMultiplierFallsBackToDefault() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 08e196db5..e506b7160 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -135,6 +135,19 @@ final class QuickPaySpendStoreTests: XCTestCase { let reservation = QuickPaySpendReservation(amountCents: 500, dayKey: "2026-08-15") let json = """ { + "cachedRates": [], + "paidOrders": {}, + "onchainAddress": "", + "bolt11": "", + "bolt11PaymentHash": "", + "bip21": "", + "balance": null, + "backupStatuses": {}, + "deletedActivities": [], + "pendingBoostActivities": [], + "backgroundReceive": null, + "addressSearchLastUsedReceiveIndexes": {}, + "addressSearchLastUsedChangeIndexes": {}, "quickPaySpendDayKey": "2026-08-15", "quickPaySpentCentsToday": 500, "quickPayReservations": { @@ -150,6 +163,7 @@ final class QuickPaySpendStoreTests: XCTestCase { reservations: cache.quickPayReservations ) + XCTAssertNil(cache.hasSeenQuickpayIntro) XCTAssertEqual(cache.quickPaySpendDayKey, "2026-08-15") XCTAssertEqual(cache.quickPaySpentCentsToday, 500) XCTAssertEqual(cache.quickPayReservations["abc"], reservation) @@ -157,6 +171,16 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertEqual(sut.reservation(paymentHash: "abc"), reservation) } + func testTryReserveReturnsNilWhenAmountExceedsThresholdSats() throws { + let tightRates = QuickPaySpendRates( + satsToUsdCents: { sats in Int64(sats) / 2 }, + usdToSats: { _ in 100 } + ) + + XCTAssertNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: tightRates)) + XCTAssertEqual(sut.spentCentsToday(), 0) + } + func testBackupSnapshotRoundTripsSpendAndReservations() throws { let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) sut.remember(paymentHash: "abc", reservation: reserved) @@ -214,7 +238,7 @@ final class QuickPaySpendStoreTests: XCTestCase { func testTryReserveFailsWithConversionErrorWhenRatesAreUnavailable() { let missingRates = QuickPaySpendRates( satsToUsdCents: { _ in nil }, - usdToSats: { _ in nil } + usdToSats: { usd in UInt64(usd * 200) } ) XCTAssertThrowsError( From 6c79029f4089c0c755bd347f0cd26addef150e35 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 21:16:46 +0200 Subject: [PATCH 14/27] fix: drop intro from settings backup mapping --- Bitkit/Models/SettingsBackupConfig.swift | 2 -- BitkitTests/AddressTypeSettingsTests.swift | 15 --------------- 2 files changed, 17 deletions(-) diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index e1086ff5f..9299fcf77 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -47,7 +47,6 @@ enum SettingsBackupConfig { "selectedAddressType": .string(optional: true), "addressTypesToMonitor": .string(optional: true), "enableQuickpay": .bool, - "hasSeenQuickpayIntro": .bool, "showWidgets": .bool, "swipeBalanceToHide": .bool, "hideBalance": .bool, @@ -70,7 +69,6 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", - "hasSeenQuickpayIntro": "quickPayIntroSeen", "quickpayAmount": "quickPayAmount", "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index 70f2f0aad..e6a71bac6 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -16,7 +16,6 @@ final class AddressTypeSettingsTests: XCTestCase { override func tearDown() { settings.resetToDefaults() - UserDefaults.standard.removeObject(forKey: "hasSeenQuickpayIntro") super.tearDown() } @@ -299,20 +298,6 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(settings.quickpayAmount, 1) } - func testRestoresQuickPaySettingsFromAndroidSettingsDataKeys() { - settings.restoreSettingsDictionary([ - "isQuickPayEnabled": true, - "quickPayAmount": 1, - "quickPayDailyLimitMultiplier": 50, - "quickPayIntroSeen": true, - ]) - - XCTAssertEqual(settings.enableQuickpay, true) - XCTAssertEqual(settings.quickpayAmount, 1) - XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 50) - XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) - } - func testInvalidDailyLimitMultiplierFallsBackToDefault() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) From 7d790809cb5c526a9db5544d9140c0d1c0daf1c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 21:19:17 +0200 Subject: [PATCH 15/27] fix: map quickpay intro to android settings key --- Bitkit/Models/SettingsBackupConfig.swift | 2 ++ BitkitTests/AddressTypeSettingsTests.swift | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 9299fcf77..e1086ff5f 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -47,6 +47,7 @@ enum SettingsBackupConfig { "selectedAddressType": .string(optional: true), "addressTypesToMonitor": .string(optional: true), "enableQuickpay": .bool, + "hasSeenQuickpayIntro": .bool, "showWidgets": .bool, "swipeBalanceToHide": .bool, "hideBalance": .bool, @@ -69,6 +70,7 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", + "hasSeenQuickpayIntro": "quickPayIntroSeen", "quickpayAmount": "quickPayAmount", "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index e6a71bac6..5ec4b8a2d 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -16,6 +16,7 @@ final class AddressTypeSettingsTests: XCTestCase { override func tearDown() { settings.resetToDefaults() + UserDefaults.standard.removeObject(forKey: "hasSeenQuickpayIntro") super.tearDown() } @@ -255,11 +256,13 @@ final class AddressTypeSettingsTests: XCTestCase { settings.enableQuickpay = true settings.quickpayDailyLimitMultiplier = 10 settings.quickpayAmount = 1 + UserDefaults.standard.set(true, forKey: "hasSeenQuickpayIntro") UserDefaults.standard.synchronize() let backupDict = settings.getSettingsDictionary() settings.resetToDefaults() + UserDefaults.standard.removeObject(forKey: "hasSeenQuickpayIntro") UserDefaults.standard.synchronize() XCTAssertEqual(settings.selectedAddressType, .nativeSegwit, "Should be default after reset") @@ -284,6 +287,9 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertNil(backupDict["quickpayDailyLimitMultiplier"]) XCTAssertEqual(backupDict["quickPayAmount"] as? Int, 1) XCTAssertNil(backupDict["quickpayAmount"]) + XCTAssertEqual(backupDict["quickPayIntroSeen"] as? Bool, true) + XCTAssertNil(backupDict["hasSeenQuickpayIntro"]) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) } func testRestoresDailyLimitMultiplierFromAndroidKey() { @@ -298,6 +304,20 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(settings.quickpayAmount, 1) } + func testRestoresQuickPaySettingsFromAndroidSettingsDataKeys() { + settings.restoreSettingsDictionary([ + "isQuickPayEnabled": true, + "quickPayAmount": 1, + "quickPayDailyLimitMultiplier": 50, + "quickPayIntroSeen": true, + ]) + + XCTAssertEqual(settings.enableQuickpay, true) + XCTAssertEqual(settings.quickpayAmount, 1) + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 50) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) + } + func testInvalidDailyLimitMultiplierFallsBackToDefault() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) From b80be42f0a551278b02826977f22db0048df6794 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 21:21:10 +0200 Subject: [PATCH 16/27] fix: keep settings quickpay intro over cache --- Bitkit/ViewModels/SettingsViewModel.swift | 2 +- BitkitTests/AddressTypeSettingsTests.swift | 26 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index b9e1dbde1..d9024a4cf 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -902,7 +902,7 @@ class SettingsViewModel: NSObject, ObservableObject { if let hasSeenNotificationsIntro = cache.hasSeenNotificationsIntro { defaults.set(hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") } - if let hasSeenQuickpayIntro = cache.hasSeenQuickpayIntro { + if defaults.object(forKey: "hasSeenQuickpayIntro") == nil, let hasSeenQuickpayIntro = cache.hasSeenQuickpayIntro { defaults.set(hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") } if let hasSeenShopIntro = cache.hasSeenShopIntro { diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index 5ec4b8a2d..a8c08f435 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -318,6 +318,32 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) } + func testCacheRestoreDoesNotClobberSettingsQuickPayIntro() { + settings.restoreSettingsDictionary(["quickPayIntroSeen": true]) + settings.restoreAppCacheData( + AppCacheData( + hasSeenContactsIntro: false, + hasSeenProfileIntro: false, + hasSeenNotificationsIntro: false, + hasSeenQuickpayIntro: false, + hasSeenShopIntro: false, + hasSeenTransferIntro: false, + hasSeenTransferToSpendingIntro: false, + hasSeenTransferToSavingsIntro: false, + hasSeenWidgetsIntro: false, + hasDismissedWidgetsOnboardingHint: false, + appUpdateIgnoreTimestamp: 0, + backupIgnoreTimestamp: 0, + highBalanceIgnoreCount: 0, + highBalanceIgnoreTimestamp: 0, + dismissedSuggestions: [], + lastUsedTags: [] + ) + ) + + XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) + } + func testInvalidDailyLimitMultiplierFallsBackToDefault() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) From 58fb64b22c2b3f1302ed56e4e6e655c1b9459b8d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 22:21:25 +0200 Subject: [PATCH 17/27] refactor: shrink quickpay backup restore helpers --- Bitkit/Models/BackupPayloads.swift | 112 +++--------------- Bitkit/Models/SettingsBackupConfig.swift | 1 - .../Utilities/PaymentNavigationHelper.swift | 14 +-- Bitkit/ViewModels/SettingsViewModel.swift | 78 ++++-------- BitkitTests/QuickPaySpendStoreTests.swift | 8 +- 5 files changed, 50 insertions(+), 163 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 8182534d5..d8a3ec2a6 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -33,99 +33,25 @@ struct PubkySessionBackupV1: Codable, Equatable { } struct AppCacheData: Codable { - let hasSeenContactsIntro: Bool? - let hasSeenProfileIntro: Bool? - let hasSeenNotificationsIntro: Bool? - let hasSeenQuickpayIntro: Bool? - let hasSeenShopIntro: Bool? - let hasSeenTransferIntro: Bool? - let hasSeenTransferToSpendingIntro: Bool? - let hasSeenTransferToSavingsIntro: Bool? - let hasSeenWidgetsIntro: Bool? - let hasDismissedWidgetsOnboardingHint: Bool? - let appUpdateIgnoreTimestamp: TimeInterval? - let backupIgnoreTimestamp: TimeInterval? - let highBalanceIgnoreCount: Int? - let highBalanceIgnoreTimestamp: TimeInterval? - let dismissedSuggestions: [String]? - let lastUsedTags: [String]? - let quickPaySpendDayKey: String - let quickPaySpentCentsToday: Int64 - let quickPayReservations: [String: QuickPaySpendReservation] - - init( - hasSeenContactsIntro: Bool, - hasSeenProfileIntro: Bool, - hasSeenNotificationsIntro: Bool, - hasSeenQuickpayIntro: Bool, - hasSeenShopIntro: Bool, - hasSeenTransferIntro: Bool, - hasSeenTransferToSpendingIntro: Bool, - hasSeenTransferToSavingsIntro: Bool, - hasSeenWidgetsIntro: Bool, - hasDismissedWidgetsOnboardingHint: Bool, - appUpdateIgnoreTimestamp: TimeInterval, - backupIgnoreTimestamp: TimeInterval, - highBalanceIgnoreCount: Int, - highBalanceIgnoreTimestamp: TimeInterval, - dismissedSuggestions: [String], - lastUsedTags: [String], - quickPaySpendDayKey: String = "", - quickPaySpentCentsToday: Int64 = 0, - quickPayReservations: [String: QuickPaySpendReservation] = [:] - ) { - self.hasSeenContactsIntro = hasSeenContactsIntro - self.hasSeenProfileIntro = hasSeenProfileIntro - self.hasSeenNotificationsIntro = hasSeenNotificationsIntro - self.hasSeenQuickpayIntro = hasSeenQuickpayIntro - self.hasSeenShopIntro = hasSeenShopIntro - self.hasSeenTransferIntro = hasSeenTransferIntro - self.hasSeenTransferToSpendingIntro = hasSeenTransferToSpendingIntro - self.hasSeenTransferToSavingsIntro = hasSeenTransferToSavingsIntro - self.hasSeenWidgetsIntro = hasSeenWidgetsIntro - self.hasDismissedWidgetsOnboardingHint = hasDismissedWidgetsOnboardingHint - self.appUpdateIgnoreTimestamp = appUpdateIgnoreTimestamp - self.backupIgnoreTimestamp = backupIgnoreTimestamp - self.highBalanceIgnoreCount = highBalanceIgnoreCount - self.highBalanceIgnoreTimestamp = highBalanceIgnoreTimestamp - self.dismissedSuggestions = dismissedSuggestions - self.lastUsedTags = lastUsedTags - self.quickPaySpendDayKey = quickPaySpendDayKey - self.quickPaySpentCentsToday = quickPaySpentCentsToday - self.quickPayReservations = quickPayReservations - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - hasSeenContactsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenContactsIntro) - hasSeenProfileIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenProfileIntro) - hasSeenNotificationsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenNotificationsIntro) - hasSeenQuickpayIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenQuickpayIntro) - hasSeenShopIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenShopIntro) - hasSeenTransferIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferIntro) - hasSeenTransferToSpendingIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSpendingIntro) - hasSeenTransferToSavingsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenTransferToSavingsIntro) - hasSeenWidgetsIntro = try c.decodeIfPresent(Bool.self, forKey: .hasSeenWidgetsIntro) - hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) - appUpdateIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) - backupIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .backupIgnoreTimestamp) - highBalanceIgnoreCount = try c.decodeIfPresent(Int.self, forKey: .highBalanceIgnoreCount) - highBalanceIgnoreTimestamp = try c.decodeIfPresent(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) - dismissedSuggestions = try c.decodeIfPresent([String].self, forKey: .dismissedSuggestions) - lastUsedTags = try c.decodeIfPresent([String].self, forKey: .lastUsedTags) - quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) ?? "" - quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) ?? 0 - quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) ?? [:] - } - - private enum CodingKeys: String, CodingKey { - case hasSeenContactsIntro, hasSeenProfileIntro, hasSeenNotificationsIntro, hasSeenQuickpayIntro - case hasSeenShopIntro, hasSeenTransferIntro, hasSeenTransferToSpendingIntro, hasSeenTransferToSavingsIntro - case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint - case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp - case dismissedSuggestions, lastUsedTags - case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations - } + var hasSeenContactsIntro: Bool? + var hasSeenProfileIntro: Bool? + var hasSeenNotificationsIntro: Bool? + var hasSeenQuickpayIntro: Bool? + var hasSeenShopIntro: Bool? + var hasSeenTransferIntro: Bool? + var hasSeenTransferToSpendingIntro: Bool? + var hasSeenTransferToSavingsIntro: Bool? + var hasSeenWidgetsIntro: Bool? + var hasDismissedWidgetsOnboardingHint: Bool? + var appUpdateIgnoreTimestamp: TimeInterval? + var backupIgnoreTimestamp: TimeInterval? + var highBalanceIgnoreCount: Int? + var highBalanceIgnoreTimestamp: TimeInterval? + var dismissedSuggestions: [String]? + var lastUsedTags: [String]? + var quickPaySpendDayKey: String? = nil + var quickPaySpentCentsToday: Int64? = nil + var quickPayReservations: [String: QuickPaySpendReservation]? = nil } struct BlocktankBackupV1: Codable { diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index e1086ff5f..93227424d 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -19,7 +19,6 @@ enum SettingsBackupConfig { "hasSeenContactsIntro", "hasSeenProfileIntro", "hasSeenNotificationsIntro", - "hasSeenQuickpayIntro", "hasSeenShopIntro", "hasSeenTransferIntro", "hasSeenTransferToSpendingIntro", diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 010f78b34..eda7d720f 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -180,19 +180,7 @@ struct PaymentNavigationHelper { switch route { case .quickpay: - if let lnurlPayData = app.lnurlPayData { - return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount - } - - if let invoice = app.scannedLightningInvoice { - return invoice.amountSatoshis == 0 ? .amount : .confirm - } - - if app.scannedOnchainInvoice != nil { - return .amount - } - - return route + return confirmRouteAfterQuickPayCap(app: app) case .confirm: if let invoice = app.scannedLightningInvoice { return invoice.amountSatoshis == 0 ? .amount : .confirm diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index d9024a4cf..86577dde0 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -891,60 +891,34 @@ class SettingsViewModel: NSObject, ObservableObject { ) } - /// Restores app cache data from backup func restoreAppCacheData(_ cache: AppCacheData) { - if let hasSeenContactsIntro = cache.hasSeenContactsIntro { - defaults.set(hasSeenContactsIntro, forKey: "hasSeenContactsIntro") - } - if let hasSeenProfileIntro = cache.hasSeenProfileIntro { - defaults.set(hasSeenProfileIntro, forKey: "hasSeenProfileIntro") - } - if let hasSeenNotificationsIntro = cache.hasSeenNotificationsIntro { - defaults.set(hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") - } - if defaults.object(forKey: "hasSeenQuickpayIntro") == nil, let hasSeenQuickpayIntro = cache.hasSeenQuickpayIntro { - defaults.set(hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") - } - if let hasSeenShopIntro = cache.hasSeenShopIntro { - defaults.set(hasSeenShopIntro, forKey: "hasSeenShopIntro") - } - if let hasSeenTransferIntro = cache.hasSeenTransferIntro { - defaults.set(hasSeenTransferIntro, forKey: "hasSeenTransferIntro") - } - if let hasSeenTransferToSpendingIntro = cache.hasSeenTransferToSpendingIntro { - defaults.set(hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") - } - if let hasSeenTransferToSavingsIntro = cache.hasSeenTransferToSavingsIntro { - defaults.set(hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") - } - if let hasSeenWidgetsIntro = cache.hasSeenWidgetsIntro { - defaults.set(hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") - } - if let hasDismissedWidgetsOnboardingHint = cache.hasDismissedWidgetsOnboardingHint { - defaults.set(hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") - } - if let appUpdateIgnoreTimestamp = cache.appUpdateIgnoreTimestamp { - defaults.set(appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") - } - if let backupIgnoreTimestamp = cache.backupIgnoreTimestamp { - defaults.set(backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") - } - if let highBalanceIgnoreCount = cache.highBalanceIgnoreCount { - defaults.set(highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") - } - if let highBalanceIgnoreTimestamp = cache.highBalanceIgnoreTimestamp { - defaults.set(highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") - } - if let dismissedSuggestions = cache.dismissedSuggestions { - defaults.set(dismissedSuggestions, forKey: "dismissedSuggestions") - } - if let lastUsedTags = cache.lastUsedTags { - defaults.set(lastUsedTags, forKey: "lastUsedTags") - } + setIfPresent(cache.hasSeenContactsIntro, forKey: "hasSeenContactsIntro") + setIfPresent(cache.hasSeenProfileIntro, forKey: "hasSeenProfileIntro") + setIfPresent(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") + if defaults.object(forKey: "hasSeenQuickpayIntro") == nil { + setIfPresent(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") + } + setIfPresent(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") + setIfPresent(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") + setIfPresent(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") + setIfPresent(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") + setIfPresent(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") + setIfPresent(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") + setIfPresent(cache.appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") + setIfPresent(cache.backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") + setIfPresent(cache.highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") + setIfPresent(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") + setIfPresent(cache.dismissedSuggestions, forKey: "dismissedSuggestions") + setIfPresent(cache.lastUsedTags, forKey: "lastUsedTags") QuickPaySpendStore.shared.restoreFromBackup( - dayKey: cache.quickPaySpendDayKey, - spentCents: cache.quickPaySpentCentsToday, - reservations: cache.quickPayReservations + dayKey: cache.quickPaySpendDayKey ?? "", + spentCents: cache.quickPaySpentCentsToday ?? 0, + reservations: cache.quickPayReservations ?? [:] ) } + + private func setIfPresent(_ value: Any?, forKey key: String) { + guard let value else { return } + defaults.set(value, forKey: key) + } } diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index e506b7160..44ba64f2f 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -158,15 +158,15 @@ final class QuickPaySpendStoreTests: XCTestCase { let cache = try JSONDecoder().decode(AppCacheData.self, from: json) sut.restoreFromBackup( - dayKey: cache.quickPaySpendDayKey, - spentCents: cache.quickPaySpentCentsToday, - reservations: cache.quickPayReservations + dayKey: cache.quickPaySpendDayKey ?? "", + spentCents: cache.quickPaySpentCentsToday ?? 0, + reservations: cache.quickPayReservations ?? [:] ) XCTAssertNil(cache.hasSeenQuickpayIntro) XCTAssertEqual(cache.quickPaySpendDayKey, "2026-08-15") XCTAssertEqual(cache.quickPaySpentCentsToday, 500) - XCTAssertEqual(cache.quickPayReservations["abc"], reservation) + XCTAssertEqual(cache.quickPayReservations?["abc"], reservation) XCTAssertEqual(sut.spentCentsToday(), 500) XCTAssertEqual(sut.reservation(paymentHash: "abc"), reservation) } From 4723c384d6926747891b07ee56ee59b06c2416e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 23:06:50 +0200 Subject: [PATCH 18/27] fix: keep production backup keys plus android aliases --- Bitkit/Models/SettingsBackupConfig.swift | 4 +--- Bitkit/ViewModels/SettingsViewModel.swift | 21 +++++++++++++++++++-- BitkitTests/AddressTypeSettingsTests.swift | 3 +-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index 93227424d..fe8a8769c 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -19,6 +19,7 @@ enum SettingsBackupConfig { "hasSeenContactsIntro", "hasSeenProfileIntro", "hasSeenNotificationsIntro", + "hasSeenQuickpayIntro", "hasSeenShopIntro", "hasSeenTransferIntro", "hasSeenTransferToSpendingIntro", @@ -46,7 +47,6 @@ enum SettingsBackupConfig { "selectedAddressType": .string(optional: true), "addressTypesToMonitor": .string(optional: true), "enableQuickpay": .bool, - "hasSeenQuickpayIntro": .bool, "showWidgets": .bool, "swipeBalanceToHide": .bool, "hideBalance": .bool, @@ -69,8 +69,6 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", - "hasSeenQuickpayIntro": "quickPayIntroSeen", - "quickpayAmount": "quickPayAmount", "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", // Note: PIN settings are intentionally NOT backed up for security diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 86577dde0..6ca991190 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -705,7 +705,11 @@ class SettingsViewModel: NSObject, ObservableObject { } else { let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[key] ?? key if key == "quickpayAmount" || key == "quickpayDailyLimitMultiplier", let doubleValue = value as? Double { - dict[androidKey] = Int(doubleValue) + let encoded = Int(doubleValue) + dict[androidKey] = encoded + if key == "quickpayAmount" { + dict["quickPayAmount"] = encoded + } } else { dict[androidKey] = value } @@ -725,6 +729,10 @@ class SettingsViewModel: NSObject, ObservableObject { dict["isDevModeEnabled"] = Env.isDebug && Env.network != .bitcoin + if defaults.object(forKey: "hasSeenQuickpayIntro") != nil { + dict["quickPayIntroSeen"] = defaults.bool(forKey: "hasSeenQuickpayIntro") + } + return dict } @@ -786,7 +794,12 @@ class SettingsViewModel: NSObject, ObservableObject { } let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[iosKey] ?? iosKey - guard let value = dict[androidKey] ?? dict[iosKey] else { + let value: Any? = if iosKey == "quickpayAmount" { + dict["quickPayAmount"] ?? dict["quickpayAmount"] + } else { + dict[androidKey] ?? dict[iosKey] + } + guard let value else { defaults.removeObject(forKey: iosKey) continue } @@ -821,6 +834,10 @@ class SettingsViewModel: NSObject, ObservableObject { } } + if let seen = dict["quickPayIntroSeen"] as? Bool { + defaults.set(seen, forKey: "hasSeenQuickpayIntro") + } + if let electrumServerUrl = dict["electrumServer"] as? String, !electrumServerUrl.isEmpty { if let server = parseElectrumServerUrlForRestore(electrumServerUrl) { electrumConfigService.saveServerConfig(server) diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index a8c08f435..b2497152a 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -284,9 +284,8 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(settings.quickpayAmount, 1, "quickpayAmount should survive full backup→reset→restore cycle") XCTAssertEqual(backupDict["quickPayDailyLimitMultiplier"] as? Int, 10) - XCTAssertNil(backupDict["quickpayDailyLimitMultiplier"]) + XCTAssertEqual(backupDict["quickpayAmount"] as? Int, 1) XCTAssertEqual(backupDict["quickPayAmount"] as? Int, 1) - XCTAssertNil(backupDict["quickpayAmount"]) XCTAssertEqual(backupDict["quickPayIntroSeen"] as? Bool, true) XCTAssertNil(backupDict["hasSeenQuickpayIntro"]) XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) From 9cffd1b4e055d0bfd034bb6326f27c28c5843589 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 23:21:51 +0200 Subject: [PATCH 19/27] fix: restore production AppCacheData decoder Keep existing cache fields required. Only the new spend ledger keys are optional. --- Bitkit/Models/BackupPayloads.swift | 112 +++++++++++++++++---- Bitkit/ViewModels/SettingsViewModel.swift | 61 ++++------- BitkitTests/AddressTypeSettingsTests.swift | 57 ----------- BitkitTests/QuickPaySpendStoreTests.swift | 31 +++--- 4 files changed, 128 insertions(+), 133 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index d8a3ec2a6..2d885972f 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -33,25 +33,99 @@ struct PubkySessionBackupV1: Codable, Equatable { } struct AppCacheData: Codable { - var hasSeenContactsIntro: Bool? - var hasSeenProfileIntro: Bool? - var hasSeenNotificationsIntro: Bool? - var hasSeenQuickpayIntro: Bool? - var hasSeenShopIntro: Bool? - var hasSeenTransferIntro: Bool? - var hasSeenTransferToSpendingIntro: Bool? - var hasSeenTransferToSavingsIntro: Bool? - var hasSeenWidgetsIntro: Bool? - var hasDismissedWidgetsOnboardingHint: Bool? - var appUpdateIgnoreTimestamp: TimeInterval? - var backupIgnoreTimestamp: TimeInterval? - var highBalanceIgnoreCount: Int? - var highBalanceIgnoreTimestamp: TimeInterval? - var dismissedSuggestions: [String]? - var lastUsedTags: [String]? - var quickPaySpendDayKey: String? = nil - var quickPaySpentCentsToday: Int64? = nil - var quickPayReservations: [String: QuickPaySpendReservation]? = nil + let hasSeenContactsIntro: Bool + let hasSeenProfileIntro: Bool + let hasSeenNotificationsIntro: Bool + let hasSeenQuickpayIntro: Bool + let hasSeenShopIntro: Bool + let hasSeenTransferIntro: Bool + let hasSeenTransferToSpendingIntro: Bool + let hasSeenTransferToSavingsIntro: Bool + let hasSeenWidgetsIntro: Bool + let hasDismissedWidgetsOnboardingHint: Bool + let appUpdateIgnoreTimestamp: TimeInterval + let backupIgnoreTimestamp: TimeInterval + let highBalanceIgnoreCount: Int + let highBalanceIgnoreTimestamp: TimeInterval + let dismissedSuggestions: [String] + let lastUsedTags: [String] + let quickPaySpendDayKey: String? + let quickPaySpentCentsToday: Int64? + let quickPayReservations: [String: QuickPaySpendReservation]? + + init( + hasSeenContactsIntro: Bool, + hasSeenProfileIntro: Bool, + hasSeenNotificationsIntro: Bool, + hasSeenQuickpayIntro: Bool, + hasSeenShopIntro: Bool, + hasSeenTransferIntro: Bool, + hasSeenTransferToSpendingIntro: Bool, + hasSeenTransferToSavingsIntro: Bool, + hasSeenWidgetsIntro: Bool, + hasDismissedWidgetsOnboardingHint: Bool, + appUpdateIgnoreTimestamp: TimeInterval, + backupIgnoreTimestamp: TimeInterval, + highBalanceIgnoreCount: Int, + highBalanceIgnoreTimestamp: TimeInterval, + dismissedSuggestions: [String], + lastUsedTags: [String], + quickPaySpendDayKey: String? = nil, + quickPaySpentCentsToday: Int64? = nil, + quickPayReservations: [String: QuickPaySpendReservation]? = nil + ) { + self.hasSeenContactsIntro = hasSeenContactsIntro + self.hasSeenProfileIntro = hasSeenProfileIntro + self.hasSeenNotificationsIntro = hasSeenNotificationsIntro + self.hasSeenQuickpayIntro = hasSeenQuickpayIntro + self.hasSeenShopIntro = hasSeenShopIntro + self.hasSeenTransferIntro = hasSeenTransferIntro + self.hasSeenTransferToSpendingIntro = hasSeenTransferToSpendingIntro + self.hasSeenTransferToSavingsIntro = hasSeenTransferToSavingsIntro + self.hasSeenWidgetsIntro = hasSeenWidgetsIntro + self.hasDismissedWidgetsOnboardingHint = hasDismissedWidgetsOnboardingHint + self.appUpdateIgnoreTimestamp = appUpdateIgnoreTimestamp + self.backupIgnoreTimestamp = backupIgnoreTimestamp + self.highBalanceIgnoreCount = highBalanceIgnoreCount + self.highBalanceIgnoreTimestamp = highBalanceIgnoreTimestamp + self.dismissedSuggestions = dismissedSuggestions + self.lastUsedTags = lastUsedTags + self.quickPaySpendDayKey = quickPaySpendDayKey + self.quickPaySpentCentsToday = quickPaySpentCentsToday + self.quickPayReservations = quickPayReservations + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + hasSeenContactsIntro = try c.decode(Bool.self, forKey: .hasSeenContactsIntro) + hasSeenProfileIntro = try c.decode(Bool.self, forKey: .hasSeenProfileIntro) + hasSeenNotificationsIntro = try c.decode(Bool.self, forKey: .hasSeenNotificationsIntro) + hasSeenQuickpayIntro = try c.decode(Bool.self, forKey: .hasSeenQuickpayIntro) + hasSeenShopIntro = try c.decode(Bool.self, forKey: .hasSeenShopIntro) + hasSeenTransferIntro = try c.decode(Bool.self, forKey: .hasSeenTransferIntro) + hasSeenTransferToSpendingIntro = try c.decode(Bool.self, forKey: .hasSeenTransferToSpendingIntro) + hasSeenTransferToSavingsIntro = try c.decode(Bool.self, forKey: .hasSeenTransferToSavingsIntro) + hasSeenWidgetsIntro = try c.decode(Bool.self, forKey: .hasSeenWidgetsIntro) + hasDismissedWidgetsOnboardingHint = try c.decodeIfPresent(Bool.self, forKey: .hasDismissedWidgetsOnboardingHint) ?? false + appUpdateIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .appUpdateIgnoreTimestamp) + backupIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .backupIgnoreTimestamp) + highBalanceIgnoreCount = try c.decode(Int.self, forKey: .highBalanceIgnoreCount) + highBalanceIgnoreTimestamp = try c.decode(TimeInterval.self, forKey: .highBalanceIgnoreTimestamp) + dismissedSuggestions = try c.decode([String].self, forKey: .dismissedSuggestions) + lastUsedTags = try c.decode([String].self, forKey: .lastUsedTags) + quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) + quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) + quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) + } + + private enum CodingKeys: String, CodingKey { + case hasSeenContactsIntro, hasSeenProfileIntro, hasSeenNotificationsIntro, hasSeenQuickpayIntro + case hasSeenShopIntro, hasSeenTransferIntro, hasSeenTransferToSpendingIntro, hasSeenTransferToSavingsIntro + case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint + case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp + case dismissedSuggestions, lastUsedTags + case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations + } } struct BlocktankBackupV1: Codable { diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 6ca991190..d1cd2bc19 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -705,11 +705,7 @@ class SettingsViewModel: NSObject, ObservableObject { } else { let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[key] ?? key if key == "quickpayAmount" || key == "quickpayDailyLimitMultiplier", let doubleValue = value as? Double { - let encoded = Int(doubleValue) - dict[androidKey] = encoded - if key == "quickpayAmount" { - dict["quickPayAmount"] = encoded - } + dict[androidKey] = Int(doubleValue) } else { dict[androidKey] = value } @@ -729,10 +725,6 @@ class SettingsViewModel: NSObject, ObservableObject { dict["isDevModeEnabled"] = Env.isDebug && Env.network != .bitcoin - if defaults.object(forKey: "hasSeenQuickpayIntro") != nil { - dict["quickPayIntroSeen"] = defaults.bool(forKey: "hasSeenQuickpayIntro") - } - return dict } @@ -794,12 +786,7 @@ class SettingsViewModel: NSObject, ObservableObject { } let androidKey = SettingsBackupConfig.iosToAndroidFieldMapping[iosKey] ?? iosKey - let value: Any? = if iosKey == "quickpayAmount" { - dict["quickPayAmount"] ?? dict["quickpayAmount"] - } else { - dict[androidKey] ?? dict[iosKey] - } - guard let value else { + guard let value = dict[androidKey] ?? dict[iosKey] else { defaults.removeObject(forKey: iosKey) continue } @@ -834,10 +821,6 @@ class SettingsViewModel: NSObject, ObservableObject { } } - if let seen = dict["quickPayIntroSeen"] as? Bool { - defaults.set(seen, forKey: "hasSeenQuickpayIntro") - } - if let electrumServerUrl = dict["electrumServer"] as? String, !electrumServerUrl.isEmpty { if let server = parseElectrumServerUrlForRestore(electrumServerUrl) { electrumConfigService.saveServerConfig(server) @@ -908,34 +891,28 @@ class SettingsViewModel: NSObject, ObservableObject { ) } + /// Restores app cache data from backup func restoreAppCacheData(_ cache: AppCacheData) { - setIfPresent(cache.hasSeenContactsIntro, forKey: "hasSeenContactsIntro") - setIfPresent(cache.hasSeenProfileIntro, forKey: "hasSeenProfileIntro") - setIfPresent(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") - if defaults.object(forKey: "hasSeenQuickpayIntro") == nil { - setIfPresent(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") - } - setIfPresent(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") - setIfPresent(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") - setIfPresent(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") - setIfPresent(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") - setIfPresent(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") - setIfPresent(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") - setIfPresent(cache.appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") - setIfPresent(cache.backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") - setIfPresent(cache.highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") - setIfPresent(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") - setIfPresent(cache.dismissedSuggestions, forKey: "dismissedSuggestions") - setIfPresent(cache.lastUsedTags, forKey: "lastUsedTags") + defaults.set(cache.hasSeenContactsIntro, forKey: "hasSeenContactsIntro") + defaults.set(cache.hasSeenProfileIntro, forKey: "hasSeenProfileIntro") + defaults.set(cache.hasSeenNotificationsIntro, forKey: "hasSeenNotificationsIntro") + defaults.set(cache.hasSeenQuickpayIntro, forKey: "hasSeenQuickpayIntro") + defaults.set(cache.hasSeenShopIntro, forKey: "hasSeenShopIntro") + defaults.set(cache.hasSeenTransferIntro, forKey: "hasSeenTransferIntro") + defaults.set(cache.hasSeenTransferToSpendingIntro, forKey: "hasSeenTransferToSpendingIntro") + defaults.set(cache.hasSeenTransferToSavingsIntro, forKey: "hasSeenTransferToSavingsIntro") + defaults.set(cache.hasSeenWidgetsIntro, forKey: "hasSeenWidgetsIntro") + defaults.set(cache.hasDismissedWidgetsOnboardingHint, forKey: "hasDismissedWidgetsOnboardingHint") + defaults.set(cache.appUpdateIgnoreTimestamp, forKey: "appUpdateIgnoreTimestamp") + defaults.set(cache.backupIgnoreTimestamp, forKey: "backupIgnoreTimestamp") + defaults.set(cache.highBalanceIgnoreCount, forKey: "highBalanceIgnoreCount") + defaults.set(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") + defaults.set(cache.dismissedSuggestions, forKey: "dismissedSuggestions") + defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") QuickPaySpendStore.shared.restoreFromBackup( dayKey: cache.quickPaySpendDayKey ?? "", spentCents: cache.quickPaySpentCentsToday ?? 0, reservations: cache.quickPayReservations ?? [:] ) } - - private func setIfPresent(_ value: Any?, forKey key: String) { - guard let value else { return } - defaults.set(value, forKey: key) - } } diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index b2497152a..c4c3a2e14 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -16,7 +16,6 @@ final class AddressTypeSettingsTests: XCTestCase { override func tearDown() { settings.resetToDefaults() - UserDefaults.standard.removeObject(forKey: "hasSeenQuickpayIntro") super.tearDown() } @@ -255,14 +254,11 @@ final class AddressTypeSettingsTests: XCTestCase { settings.hideBalance = true settings.enableQuickpay = true settings.quickpayDailyLimitMultiplier = 10 - settings.quickpayAmount = 1 - UserDefaults.standard.set(true, forKey: "hasSeenQuickpayIntro") UserDefaults.standard.synchronize() let backupDict = settings.getSettingsDictionary() settings.resetToDefaults() - UserDefaults.standard.removeObject(forKey: "hasSeenQuickpayIntro") UserDefaults.standard.synchronize() XCTAssertEqual(settings.selectedAddressType, .nativeSegwit, "Should be default after reset") @@ -281,14 +277,7 @@ final class AddressTypeSettingsTests: XCTestCase { "enableQuickpay should survive full backup→reset→restore cycle") XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 10, "quickpayDailyLimitMultiplier should survive full backup→reset→restore cycle") - XCTAssertEqual(settings.quickpayAmount, 1, - "quickpayAmount should survive full backup→reset→restore cycle") XCTAssertEqual(backupDict["quickPayDailyLimitMultiplier"] as? Int, 10) - XCTAssertEqual(backupDict["quickpayAmount"] as? Int, 1) - XCTAssertEqual(backupDict["quickPayAmount"] as? Int, 1) - XCTAssertEqual(backupDict["quickPayIntroSeen"] as? Bool, true) - XCTAssertNil(backupDict["hasSeenQuickpayIntro"]) - XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) } func testRestoresDailyLimitMultiplierFromAndroidKey() { @@ -297,52 +286,6 @@ final class AddressTypeSettingsTests: XCTestCase { XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 3) } - func testRestoresQuickpayAmountFromAndroidKey() { - settings.restoreSettingsDictionary(["quickPayAmount": 1]) - - XCTAssertEqual(settings.quickpayAmount, 1) - } - - func testRestoresQuickPaySettingsFromAndroidSettingsDataKeys() { - settings.restoreSettingsDictionary([ - "isQuickPayEnabled": true, - "quickPayAmount": 1, - "quickPayDailyLimitMultiplier": 50, - "quickPayIntroSeen": true, - ]) - - XCTAssertEqual(settings.enableQuickpay, true) - XCTAssertEqual(settings.quickpayAmount, 1) - XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 50) - XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) - } - - func testCacheRestoreDoesNotClobberSettingsQuickPayIntro() { - settings.restoreSettingsDictionary(["quickPayIntroSeen": true]) - settings.restoreAppCacheData( - AppCacheData( - hasSeenContactsIntro: false, - hasSeenProfileIntro: false, - hasSeenNotificationsIntro: false, - hasSeenQuickpayIntro: false, - hasSeenShopIntro: false, - hasSeenTransferIntro: false, - hasSeenTransferToSpendingIntro: false, - hasSeenTransferToSavingsIntro: false, - hasSeenWidgetsIntro: false, - hasDismissedWidgetsOnboardingHint: false, - appUpdateIgnoreTimestamp: 0, - backupIgnoreTimestamp: 0, - highBalanceIgnoreCount: 0, - highBalanceIgnoreTimestamp: 0, - dismissedSuggestions: [], - lastUsedTags: [] - ) - ) - - XCTAssertTrue(UserDefaults.standard.bool(forKey: "hasSeenQuickpayIntro")) - } - func testInvalidDailyLimitMultiplierFallsBackToDefault() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 44ba64f2f..1b933fcb8 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -131,23 +131,25 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertNil(sut.reservation(paymentHash: "old")) } - func testAppCacheDataDecodesAndroidShapedSpendFields() throws { + func testAppCacheDataDecodesSpendFields() throws { let reservation = QuickPaySpendReservation(amountCents: 500, dayKey: "2026-08-15") let json = """ { - "cachedRates": [], - "paidOrders": {}, - "onchainAddress": "", - "bolt11": "", - "bolt11PaymentHash": "", - "bip21": "", - "balance": null, - "backupStatuses": {}, - "deletedActivities": [], - "pendingBoostActivities": [], - "backgroundReceive": null, - "addressSearchLastUsedReceiveIndexes": {}, - "addressSearchLastUsedChangeIndexes": {}, + "hasSeenContactsIntro": false, + "hasSeenProfileIntro": false, + "hasSeenNotificationsIntro": false, + "hasSeenQuickpayIntro": false, + "hasSeenShopIntro": false, + "hasSeenTransferIntro": false, + "hasSeenTransferToSpendingIntro": false, + "hasSeenTransferToSavingsIntro": false, + "hasSeenWidgetsIntro": false, + "appUpdateIgnoreTimestamp": 0, + "backupIgnoreTimestamp": 0, + "highBalanceIgnoreCount": 0, + "highBalanceIgnoreTimestamp": 0, + "dismissedSuggestions": [], + "lastUsedTags": [], "quickPaySpendDayKey": "2026-08-15", "quickPaySpentCentsToday": 500, "quickPayReservations": { @@ -163,7 +165,6 @@ final class QuickPaySpendStoreTests: XCTestCase { reservations: cache.quickPayReservations ?? [:] ) - XCTAssertNil(cache.hasSeenQuickpayIntro) XCTAssertEqual(cache.quickPaySpendDayKey, "2026-08-15") XCTAssertEqual(cache.quickPaySpentCentsToday, 500) XCTAssertEqual(cache.quickPayReservations?["abc"], reservation) From c33a00ff66aabe3c4663509aed570d220efae86e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 23:41:08 +0200 Subject: [PATCH 20/27] chore: drop unused QuickPay leftovers Remove the unused daily-limit string, defaultThresholdUsd, settings preview, and unused SendQuickpay store injection. --- .../Localization/en.lproj/Localizable.strings | 1 - Bitkit/Utilities/QuickPayLimits.swift | 1 - Bitkit/Utilities/QuickPaySpendStore.swift | 6 +++--- Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift | 8 -------- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 11 +++++------ 5 files changed, 8 insertions(+), 19 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 0fa7e5a2d..3a16a7d94 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -806,7 +806,6 @@ "settings__quickpay__settings__multiplier_format" = "{multiplier}×"; "settings__quickpay__settings__note" = "* Bitkit QuickPay exclusively supports payments from your Spending Balance."; "wallet__send_quickpay__currency_conversion" = "Currency conversion failed"; -"wallet__send_quickpay__daily_limit" = "Daily QuickPay limit reached"; "settings__security__title" = "Security And Privacy"; "settings__security__swipe_balance_to_hide" = "Swipe balance to hide"; "settings__security__hide_balance_on_open" = "Hide balance on open"; diff --git a/Bitkit/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift index 92e77801e..441106057 100644 --- a/Bitkit/Utilities/QuickPayLimits.swift +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -4,7 +4,6 @@ enum QuickPayLimits { static let usdCurrencyCode = "USD" static let thresholdSteps: [Double] = [1, 5, 10, 20, 50] static let dailyMultiplierSteps: [Double] = [1, 3, 5, 10, 50] - static let defaultThresholdUsd: Double = 5 static let defaultDailyMultiplier: Double = 5 static func amountWithFeeSats(amountSats: UInt64, feePaidSats: UInt64) -> UInt64 { diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index bac582353..6f0da45cf 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -30,9 +30,9 @@ struct QuickPaySpendRates { final class QuickPaySpendStore: @unchecked Sendable { static let shared = QuickPaySpendStore() - static let dayKeyDefaultsKey = "quickPaySpendDayKey" - static let spentCentsDefaultsKey = "quickPaySpentCentsToday" - static let reservationsDefaultsKey = "quickPayReservations" + private static let dayKeyDefaultsKey = "quickPaySpendDayKey" + private static let spentCentsDefaultsKey = "quickPaySpentCentsToday" + private static let reservationsDefaultsKey = "quickPayReservations" private let defaults: UserDefaults private let lock = NSLock() diff --git a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift index a79f9f705..3018c7aef 100644 --- a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift +++ b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift @@ -91,11 +91,3 @@ struct QuickpaySettings: View { .navigationBarHidden(true) } } - -#Preview { - NavigationStack { - QuickpaySettings() - .environmentObject(SettingsViewModel.shared) - .preferredColorScheme(.dark) - } -} diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 34f4a6c7a..aa406ba70 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -10,7 +10,6 @@ struct SendQuickpay: View { @Binding var navigationPath: [SendRoute] let routingCacheResetAttempted: Bool - var spendStore: QuickPaySpendStore = .shared var replaceQuickPay: (SendRoute) -> Void @State private var didStartPayment = false @@ -81,7 +80,7 @@ struct SendQuickpay: View { sats: nil, afterListening: { paymentHash in submittedHash = paymentHash - spendStore.remember(paymentHash: paymentHash, reservation: reservation) + QuickPaySpendStore.shared.remember(paymentHash: paymentHash, reservation: reservation) }, onTimeout: { paymentHash in app.addPendingPaymentHash(paymentHash) @@ -89,7 +88,7 @@ struct SendQuickpay: View { } ) let paymentHash = String(settled.paymentHash) - spendStore.clear(paymentHash: paymentHash) + QuickPaySpendStore.shared.clear(paymentHash: paymentHash) wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( amountSats: amountSats, feePaidSats: settled.feePaidSats @@ -100,9 +99,9 @@ struct SendQuickpay: View { return } catch { if submittedHash.isEmpty { - spendStore.releaseUnbound(reservation) + QuickPaySpendStore.shared.releaseUnbound(reservation) } else { - spendStore.release(paymentHash: submittedHash) + QuickPaySpendStore.shared.release(paymentHash: submittedHash) } throw error } @@ -114,7 +113,7 @@ struct SendQuickpay: View { } private func reserveDailySpend(amountSats: UInt64) throws -> QuickPaySpendReservation? { - let reserved = try spendStore.tryReserve( + let reserved = try QuickPaySpendStore.shared.tryReserve( amountSats: amountSats, thresholdUsd: settings.quickpayAmount, multiplier: settings.quickpayDailyLimitMultiplier, From d56dc6373b77753e26a5c41ce05cd0b255a0bc84 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 20 Aug 2026 23:43:03 +0200 Subject: [PATCH 21/27] chore: restore QuickpaySettings preview --- Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift index 3018c7aef..a79f9f705 100644 --- a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift +++ b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift @@ -91,3 +91,11 @@ struct QuickpaySettings: View { .navigationBarHidden(true) } } + +#Preview { + NavigationStack { + QuickpaySettings() + .environmentObject(SettingsViewModel.shared) + .preferredColorScheme(.dark) + } +} From a0c208ea5e58d9bb7776df135314107b21fe312a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 21 Aug 2026 00:05:01 +0200 Subject: [PATCH 22/27] chore: drop QuickpaySettings preview --- Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift index a79f9f705..3018c7aef 100644 --- a/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift +++ b/Bitkit/Views/Settings/Quickpay/QuickpaySettings.swift @@ -91,11 +91,3 @@ struct QuickpaySettings: View { .navigationBarHidden(true) } } - -#Preview { - NavigationStack { - QuickpaySettings() - .environmentObject(SettingsViewModel.shared) - .preferredColorScheme(.dark) - } -} From 8d390f15d0c0a8f613abe65b6929bc242409356f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 21 Aug 2026 14:59:48 +0200 Subject: [PATCH 23/27] fix: settle QuickPay spend through one ledger Bind the reservation to the invoice hash before send. AppViewModel only routes events to noteTerminal. Persist a canonical ledger and reconcile against LDK without treating absence as failure. --- Bitkit/AppScene.swift | 5 +- Bitkit/Models/BackupPayloads.swift | 8 +- .../QuickPayPaymentCoordinator.swift | 275 ++++++++++++++ Bitkit/Utilities/QuickPaySpendStore.swift | 357 ++++++++++++++---- Bitkit/ViewModels/AppViewModel.swift | 38 +- Bitkit/ViewModels/SettingsViewModel.swift | 6 +- Bitkit/ViewModels/WalletViewModel.swift | 26 +- Bitkit/Views/Wallets/Send/SendQuickpay.swift | 110 +----- Bitkit/Views/Wallets/Send/SendSheet.swift | 1 + .../PaymentNavigationHelperTests.swift | 18 +- .../QuickPayPaymentCoordinatorTests.swift | 27 ++ BitkitTests/QuickPaySpendStoreTests.swift | 211 ++++++----- 12 files changed, 794 insertions(+), 288 deletions(-) create mode 100644 Bitkit/Utilities/QuickPayPaymentCoordinator.swift create mode 100644 BitkitTests/QuickPayPaymentCoordinatorTests.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 92dd2a859..17d0590c7 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -150,7 +150,9 @@ struct AppScene: View { // TrezorManager bumps devicesRevision on any device/connection change. .onChange(of: trezorManager.devicesRevision) { _, _ in pushHardwareDevices() } .onChange(of: isPinVerified) { _, verified in - if verified { Task { await trezorManager.autoReconnect() } } + if verified { + Task { await trezorManager.autoReconnect() } + } } .onReceive(settings.settingsPublisher) { _ in hwWalletManager.reconcileForSettingsChange() } .onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in @@ -668,6 +670,7 @@ struct AppScene: View { walletInitShouldFinish = true app.markAppStatusInit() BackupService.shared.startObservingBackups() + QuickPayPaymentCoordinator.shared.reconcileAgainstLdk() Task { if !PaykitFeatureFlags.isUIEnabled { await retryPendingPaykitEndpointRemoval() diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 2d885972f..9304141e7 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -52,6 +52,7 @@ struct AppCacheData: Codable { let quickPaySpendDayKey: String? let quickPaySpentCentsToday: Int64? let quickPayReservations: [String: QuickPaySpendReservation]? + let quickPayLedger: QuickPayLedger? init( hasSeenContactsIntro: Bool, @@ -72,7 +73,8 @@ struct AppCacheData: Codable { lastUsedTags: [String], quickPaySpendDayKey: String? = nil, quickPaySpentCentsToday: Int64? = nil, - quickPayReservations: [String: QuickPaySpendReservation]? = nil + quickPayReservations: [String: QuickPaySpendReservation]? = nil, + quickPayLedger: QuickPayLedger? = nil ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro @@ -93,6 +95,7 @@ struct AppCacheData: Codable { self.quickPaySpendDayKey = quickPaySpendDayKey self.quickPaySpentCentsToday = quickPaySpentCentsToday self.quickPayReservations = quickPayReservations + self.quickPayLedger = quickPayLedger } init(from decoder: Decoder) throws { @@ -116,6 +119,7 @@ struct AppCacheData: Codable { quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) + quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger) } private enum CodingKeys: String, CodingKey { @@ -124,7 +128,7 @@ struct AppCacheData: Codable { case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp case dismissedSuggestions, lastUsedTags - case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations + case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations, quickPayLedger } } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift new file mode 100644 index 000000000..ac9b15123 --- /dev/null +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -0,0 +1,275 @@ +import Foundation +import LDKNode +import SwiftUI + +@MainActor +final class QuickPayPaymentCoordinator { + static let shared = QuickPayPaymentCoordinator() + + struct Presentation { + var appendRoute: (SendRoute) -> Void + var replaceQuickPay: (SendRoute) -> Void + var addPendingPaymentHash: (String) -> Void + var routingCacheResetAttempted: Bool + } + + private struct Operation { + var dispatched = false + var presentation: Presentation? + } + + private let store: QuickPaySpendStore + private let sendBolt11: (String) async throws -> String + private let listRows: () async -> [QuickPayReconcileRow]? + + private var operations: [String: Operation] = [:] + private var generation = UUID() + + var liveSubmittingHashes: Set { + Set(operations.keys) + } + + init( + store: QuickPaySpendStore = .shared, + sendBolt11: ((String) async throws -> String)? = nil, + listRows: (() async -> [QuickPayReconcileRow]?)? = nil + ) { + self.store = store + self.sendBolt11 = sendBolt11 ?? { bolt11 in + try await String(LightningService.shared.send(bolt11: bolt11)) + } + self.listRows = listRows ?? { + await LightningService.shared.listPayments()?.map(QuickPayReconcileRow.init) + } + } + + func detach() { + generation = UUID() + for hash in operations.keys { + if var op = operations[hash] { + op.presentation = nil + operations[hash] = op + } + } + } + + func pay( + app: AppViewModel, + wallet: WalletViewModel, + settings: SettingsViewModel, + currency: CurrencyViewModel, + presentation: Presentation + ) { + let generation = UUID() + self.generation = generation + Task { + await run( + generation: generation, + app: app, + wallet: wallet, + settings: settings, + currency: currency, + presentation: presentation + ) + } + } + + func reconcileAgainstLdk() { + Task { + let rows = await listRows() + store.reconcile(rows: rows, liveSubmittingHashes: liveSubmittingHashes) + } + } + + func handleSettled(paymentId: String?, paymentHash: String?) { + for key in [paymentId, paymentHash].compactMap({ $0 }) { + operations.removeValue(forKey: key) + } + } + + static func classify(_ error: Error) -> DispatchClass { + if PrivatePaykitService.isDuplicatePaymentError(error) { + return .duplicatePayment + } + guard let nodeError = error as? NodeError else { + return .ambiguous + } + switch nodeError { + case .InvalidInvoice, .InvalidAmount, .InvalidPaymentHash, .InvalidPaymentId, .InvalidNetwork: + return .preDispatchRejection + case .DuplicatePayment: + return .duplicatePayment + default: + return .ambiguous + } + } + + enum DispatchClass { + case preDispatchRejection + case duplicatePayment + case ambiguous + } + + private func run( + generation: UUID, + app: AppViewModel, + wallet: WalletViewModel, + settings: SettingsViewModel, + currency: CurrencyViewModel, + presentation: Presentation + ) async { + var bolt11: String? + do { + if let lnurlPayData = app.lnurlPayData { + wallet.sendAmountSats = lnurlPayData.minSendableSat + bolt11 = try await LnurlHelper.fetchLnurlInvoice( + data: lnurlPayData, + amountMsats: lnurlPayData.callbackAmountMsats() + ) + } else if let scannedInvoice = app.scannedLightningInvoice { + wallet.sendAmountSats = scannedInvoice.amountSatoshis + bolt11 = scannedInvoice.bolt11 + } + } catch { + guard generation == self.generation else { return } + fail(presentation, error: error, bolt11: nil) + return + } + + guard generation == self.generation else { return } + guard let bolt11 else { + fail(presentation, error: AppError(message: t("common__error_body"), debugMessage: "No Lightning invoice found"), bolt11: nil) + return + } + + let invoiceHash: String + do { + invoiceHash = try String(Bolt11Invoice.fromStr(invoiceStr: bolt11).paymentHash()) + } catch { + fail(presentation, error: error, bolt11: bolt11) + return + } + + if var existing = operations[invoiceHash] { + existing.presentation = presentation + operations[invoiceHash] = existing + return + } + + if store.hasOpenRecord(paymentHash: invoiceHash) { + operations[invoiceHash] = Operation(dispatched: true, presentation: presentation) + return + } + + guard generation == self.generation else { return } + + let amountSats = wallet.sendAmountSats ?? 0 + let reserved: QuickPayLedgerRecord? + do { + reserved = try store.reserveBound( + paymentHash: invoiceHash, + amountSats: amountSats, + thresholdUsd: settings.quickpayAmount, + multiplier: settings.quickpayDailyLimitMultiplier, + rates: .live(currency) + ) + } catch { + fail(presentation, error: error, bolt11: bolt11) + return + } + + guard let reserved else { + presentation.replaceQuickPay(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) + return + } + + guard generation == self.generation else { + store.releaseBound(paymentHash: invoiceHash) + return + } + + operations[invoiceHash] = Operation(dispatched: false, presentation: presentation) + + do { + let paymentId = try await sendBolt11(bolt11) + store.markSubmitted(invoicePaymentHash: invoiceHash, paymentId: paymentId) + if var op = operations[invoiceHash] { + op.dispatched = true + operations[invoiceHash] = op + if paymentId != invoiceHash { + operations[paymentId] = op + } + } + } catch { + await handleDispatchError(error, invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) + return + } + + _ = reserved + + guard let attached = operations[invoiceHash]?.presentation else { return } + + do { + let settled = try await wallet.waitForLightningPayment(hash: invoiceHash) { hash in + attached.addPendingPaymentHash(hash) + attached.appendRoute(.pending(paymentHash: hash, retryRoute: .quickpay, paymentRequest: bolt11)) + } + operations[invoiceHash]?.presentation?.appendRoute(.success(paymentId: String(settled.paymentHash))) + if let amountSats = wallet.sendAmountSats { + wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( + amountSats: amountSats, + feePaidSats: settled.feePaidSats + ) + } + } catch is PaymentTimeoutError { + return + } catch { + operations[invoiceHash]?.presentation?.appendRoute(.failure(SendFailureContext( + error: error, + retryRoute: .quickpay, + routingCacheResetAttempted: attached.routingCacheResetAttempted, + paymentRequest: bolt11 + ))) + } + } + + private func handleDispatchError( + _ error: Error, + invoiceHash: String, + bolt11: String, + presentation: Presentation + ) async { + let attached = operations[invoiceHash]?.presentation + switch Self.classify(error) { + case .duplicatePayment, .preDispatchRejection: + store.releaseBound(paymentHash: invoiceHash) + operations.removeValue(forKey: invoiceHash) + case .ambiguous: + await store.reconcile(rows: listRows(), liveSubmittingHashes: []) + if store.record(matching: invoiceHash) != nil { + if var op = operations[invoiceHash] { + op.dispatched = true + operations[invoiceHash] = op + } + } else { + operations.removeValue(forKey: invoiceHash) + } + } + + attached?.appendRoute(.failure(SendFailureContext( + error: error, + retryRoute: .quickpay, + routingCacheResetAttempted: presentation.routingCacheResetAttempted, + paymentRequest: bolt11 + ))) + } + + private func fail(_ presentation: Presentation, error: Error, bolt11: String?) { + presentation.appendRoute(.failure(SendFailureContext( + error: error, + retryRoute: .quickpay, + routingCacheResetAttempted: presentation.routingCacheResetAttempted, + paymentRequest: bolt11 + ))) + } +} diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 6f0da45cf..ed626230b 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -1,4 +1,5 @@ import Foundation +import LDKNode struct QuickPaySpendReservation: Codable, Equatable { let amountCents: Int64 @@ -27,12 +28,81 @@ struct QuickPaySpendRates { } } +enum QuickPayRecordPhase: String, Codable { + case submitting + case submitted +} + +struct QuickPayLedgerRecord: Codable, Equatable { + let id: String + let amountCents: Int64 + let dayKey: String + let invoicePaymentHash: String + var paymentId: String? + var phase: QuickPayRecordPhase +} + +struct QuickPayLedger: Codable, Equatable { + var version: Int + var dayKey: String + var spentCents: Int64 + var records: [QuickPayLedgerRecord] +} + +enum QuickPayTerminalOutcome: Equatable { + case none + case settledSuccess + case settledFailure +} + +struct QuickPayReconcileRow { + let paymentId: String + let invoicePaymentHash: String + let isOutboundBolt11: Bool + let status: Status + + enum Status { + case succeeded + case failed + case pending + } + + init(payment: PaymentDetails) { + paymentId = payment.id + isOutboundBolt11 = payment.direction == .outbound && { + if case .bolt11 = payment.kind { + return true + } + return false + }() + invoicePaymentHash = { + if case let .bolt11(hash, _, _, _, _) = payment.kind { + return String(hash) + } + return payment.id + }() + status = switch payment.status { + case .succeeded: .succeeded + case .failed: .failed + case .pending: .pending + } + } + + init(paymentId: String, invoicePaymentHash: String, isOutboundBolt11: Bool, status: Status) { + self.paymentId = paymentId + self.invoicePaymentHash = invoicePaymentHash + self.isOutboundBolt11 = isOutboundBolt11 + self.status = status + } +} + final class QuickPaySpendStore: @unchecked Sendable { static let shared = QuickPaySpendStore() - private static let dayKeyDefaultsKey = "quickPaySpendDayKey" - private static let spentCentsDefaultsKey = "quickPaySpentCentsToday" - private static let reservationsDefaultsKey = "quickPayReservations" + static let ledgerDefaultsKey = "quickPayLedger" + static let dayKeyDefaultsKey = "quickPaySpendDayKey" + static let spentCentsDefaultsKey = "quickPaySpentCentsToday" + static let reservationsDefaultsKey = "quickPayReservations" private let defaults: UserDefaults private let lock = NSLock() @@ -41,6 +111,9 @@ final class QuickPaySpendStore: @unchecked Sendable { init(defaults: UserDefaults = .standard, dayKey: @escaping () -> String = { QuickPaySpendStore.dayKey() }) { self.defaults = defaults dayKeyProvider = dayKey + lock.lock() + migrateLegacyIfNeededLocked() + lock.unlock() } static func dayKey(date: Date = Date(), timeZone: TimeZone = .current) -> String { @@ -87,12 +160,26 @@ final class QuickPaySpendStore: @unchecked Sendable { return false } - func tryReserve( + func hasOpenRecord(paymentHash: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return lockedRecord(matching: paymentHash) != nil + } + + func record(matching hash: String) -> QuickPayLedgerRecord? { + lock.lock() + defer { lock.unlock() } + return lockedRecord(matching: hash) + } + + func reserveBound( + paymentHash: String, amountSats: UInt64, thresholdUsd: Double, multiplier: Double, rates: QuickPaySpendRates - ) throws -> QuickPaySpendReservation? { + ) throws -> QuickPayLedgerRecord? { + guard !paymentHash.isEmpty else { return nil } guard let thresholdSats = rates.usdToSats(thresholdUsd), thresholdSats > 0, amountSats <= thresholdSats else { return nil } @@ -106,124 +193,254 @@ final class QuickPaySpendStore: @unchecked Sendable { lock.lock() defer { lock.unlock() } + if lockedRecord(matching: paymentHash) != nil { + return nil + } + let spend = lockedSpend(forDayKey: dayKeyProvider()) let (total, overflow) = spend.spentCents.addingReportingOverflow(amountCents) if overflow || total > capCents { return nil } - lockedWriteSpend(dayKey: spend.dayKey, spentCents: total) - return QuickPaySpendReservation(amountCents: amountCents, dayKey: spend.dayKey) + var ledger = lockedLedger() + lockedPrune(ledger: &ledger, currentDay: spend.dayKey) + let record = QuickPayLedgerRecord( + id: UUID().uuidString, + amountCents: amountCents, + dayKey: spend.dayKey, + invoicePaymentHash: paymentHash, + paymentId: nil, + phase: .submitting + ) + ledger.dayKey = spend.dayKey + ledger.spentCents = total + ledger.records.append(record) + lockedWriteLedger(ledger) + return record } - func remember(paymentHash: String, reservation: QuickPaySpendReservation) { - guard !paymentHash.isEmpty else { return } - + func markSubmitted(invoicePaymentHash: String, paymentId: String?) { + guard !invoicePaymentHash.isEmpty else { return } lock.lock() defer { lock.unlock() } - - var reservations = lockedReservations() - reservations[paymentHash] = reservation - lockedWriteReservations(reservations) + var ledger = lockedLedger() + guard let index = lockedRecordIndex(in: ledger, matching: invoicePaymentHash) else { return } + ledger.records[index].paymentId = paymentId + ledger.records[index].phase = .submitted + lockedWriteLedger(ledger) } - func reservation(paymentHash: String) -> QuickPaySpendReservation? { - guard !paymentHash.isEmpty else { return nil } - + @discardableResult + func noteTerminal(paymentId: String?, paymentHash: String?, success: Bool) -> QuickPayTerminalOutcome { lock.lock() defer { lock.unlock() } - return lockedReservations()[paymentHash] + var ledger = lockedLedger() + let keys = [paymentId, paymentHash].compactMap { $0 }.filter { !$0.isEmpty } + guard let index = keys.compactMap({ key in lockedRecordIndex(in: ledger, matching: key) }).first else { + return .none + } + let record = ledger.records.remove(at: index) + if !success, record.dayKey == ledger.dayKey { + ledger.spentCents = max(ledger.spentCents - record.amountCents, 0) + } + lockedWriteLedger(ledger) + return success ? .settledSuccess : .settledFailure + } + + func releaseBound(paymentHash: String) { + _ = noteTerminal(paymentId: nil, paymentHash: paymentHash, success: false) } - func release(paymentHash: String) { - guard !paymentHash.isEmpty else { return } + func reconcile(rows: [QuickPayReconcileRow]?, liveSubmittingHashes: Set) { + guard let rows else { return } lock.lock() defer { lock.unlock() } + var ledger = lockedLedger() + let currentDay = dayKeyProvider() + lockedPrune(ledger: &ledger, currentDay: currentDay) + + var didWrite = false + var remaining: [QuickPayLedgerRecord] = [] + remaining.reserveCapacity(ledger.records.count) + + for record in ledger.records { + if liveSubmittingHashes.contains(record.invoicePaymentHash) { + remaining.append(record) + continue + } + let match = rows.first { row in + row.isOutboundBolt11 && ( + row.invoicePaymentHash == record.invoicePaymentHash + || row.paymentId == record.invoicePaymentHash + || row.paymentId == record.paymentId + || (record.paymentId != nil && row.invoicePaymentHash == record.paymentId) + ) + } + guard let match else { + remaining.append(record) + continue + } + switch match.status { + case .pending: + remaining.append(record) + case .succeeded: + didWrite = true + case .failed: + if record.dayKey == ledger.dayKey { + ledger.spentCents = max(ledger.spentCents - record.amountCents, 0) + } + didWrite = true + } + } - var reservations = lockedReservations() - guard let reservation = reservations.removeValue(forKey: paymentHash) else { return } - lockedWriteReservations(reservations) - - let spend = lockedSpend(forDayKey: reservation.dayKey) - guard reservation.dayKey == spend.dayKey else { return } - lockedWriteSpend(dayKey: spend.dayKey, spentCents: max(spend.spentCents - reservation.amountCents, 0)) + if didWrite || remaining.count != ledger.records.count { + ledger.records = remaining + lockedWriteLedger(ledger) + } } - func releaseUnbound(_ reservation: QuickPaySpendReservation) { + func backupSnapshot() -> ( + dayKey: String, + spentCents: Int64, + reservations: [String: QuickPaySpendReservation], + ledger: QuickPayLedger + ) { lock.lock() defer { lock.unlock() } - - let storedDayKey = lockedStoredDayKey() - guard reservation.dayKey == storedDayKey else { return } - lockedWriteSpend(dayKey: storedDayKey, spentCents: max(lockedStoredSpentCents() - reservation.amountCents, 0)) + let ledger = lockedLedger() + var reservations: [String: QuickPaySpendReservation] = [:] + for record in ledger.records { + let value = QuickPaySpendReservation(amountCents: record.amountCents, dayKey: record.dayKey) + reservations[record.invoicePaymentHash] = value + if let paymentId = record.paymentId, paymentId != record.invoicePaymentHash { + reservations[paymentId] = value + } + } + return (ledger.dayKey, ledger.spentCents, reservations, ledger) } - func clear(paymentHash: String) { - guard !paymentHash.isEmpty else { return } - + func restoreFromBackup( + dayKey: String, + spentCents: Int64, + reservations: [String: QuickPaySpendReservation], + ledger: QuickPayLedger? = nil + ) { lock.lock() defer { lock.unlock() } - - var reservations = lockedReservations() - guard reservations.removeValue(forKey: paymentHash) != nil else { return } - lockedWriteReservations(reservations) + if let ledger, ledger.version >= 1 { + var restored = ledger + lockedPrune(ledger: &restored, currentDay: dayKeyProvider()) + lockedWriteLedger(restored) + return + } + lockedWriteLedger(Self.ledgerFromLegacy(dayKey: dayKey, spentCents: spentCents, reservations: reservations)) } - func backupSnapshot() -> (dayKey: String, spentCents: Int64, reservations: [String: QuickPaySpendReservation]) { - lock.lock() - defer { lock.unlock() } - return (lockedStoredDayKey(), lockedStoredSpentCents(), lockedReservations()) + private func migrateLegacyIfNeededLocked() { + if defaults.data(forKey: Self.ledgerDefaultsKey) != nil { + return + } + let dayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" + let spentCents = Int64(max(defaults.integer(forKey: Self.spentCentsDefaultsKey), 0)) + let reservations: [String: QuickPaySpendReservation] = if let data = defaults.data(forKey: Self.reservationsDefaultsKey), + let decoded = try? JSONDecoder().decode( + [String: QuickPaySpendReservation].self, + from: data + ) + { + decoded + } else { + [:] + } + if dayKey.isEmpty, spentCents == 0, reservations.isEmpty { + return + } + lockedWriteLedger(Self.ledgerFromLegacy(dayKey: dayKey, spentCents: spentCents, reservations: reservations)) } - func restoreFromBackup(dayKey: String, spentCents: Int64, reservations: [String: QuickPaySpendReservation]) { - lock.lock() - defer { lock.unlock() } - lockedWriteSpend(dayKey: dayKey, spentCents: max(spentCents, 0)) - lockedWriteReservations(reservations) + private static func ledgerFromLegacy( + dayKey: String, + spentCents: Int64, + reservations: [String: QuickPaySpendReservation] + ) -> QuickPayLedger { + var seen: Set = [] + var records: [QuickPayLedgerRecord] = [] + for (hash, reservation) in reservations { + if seen.contains(hash) { + continue + } + seen.insert(hash) + records.append( + QuickPayLedgerRecord( + id: UUID().uuidString, + amountCents: reservation.amountCents, + dayKey: reservation.dayKey, + invoicePaymentHash: hash, + paymentId: nil, + phase: .submitted + ) + ) + } + return QuickPayLedger(version: 1, dayKey: dayKey, spentCents: max(spentCents, 0), records: records) } private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentCents: Int64) { - let storedDayKey = lockedStoredDayKey() - let storedCents = lockedStoredSpentCents() - - if storedDayKey.isEmpty || dayKey > storedDayKey { + var ledger = lockedLedger() + if ledger.dayKey.isEmpty || dayKey > ledger.dayKey { + lockedPrune(ledger: &ledger, currentDay: dayKey) + ledger.dayKey = dayKey + ledger.spentCents = 0 + lockedWriteLedger(ledger) return (dayKey, 0) } - if dayKey == storedDayKey { - return (dayKey, storedCents) + if dayKey == ledger.dayKey { + return (dayKey, ledger.spentCents) } - return (storedDayKey, storedCents) + return (ledger.dayKey, ledger.spentCents) } - private func lockedStoredDayKey() -> String { - defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" + private func lockedPrune(ledger: inout QuickPayLedger, currentDay: String) { + guard !currentDay.isEmpty else { return } + ledger.records.removeAll { $0.dayKey < currentDay } } - private func lockedStoredSpentCents() -> Int64 { - Int64(max(defaults.integer(forKey: Self.spentCentsDefaultsKey), 0)) + private func lockedRecord(matching hash: String) -> QuickPayLedgerRecord? { + guard let index = lockedRecordIndex(in: lockedLedger(), matching: hash) else { return nil } + return lockedLedger().records[index] } - private func lockedWriteSpend(dayKey: String, spentCents: Int64) { - defaults.set(dayKey, forKey: Self.dayKeyDefaultsKey) - defaults.set(Int(clamping: spentCents), forKey: Self.spentCentsDefaultsKey) + private func lockedRecordIndex(in ledger: QuickPayLedger, matching hash: String) -> Int? { + ledger.records.firstIndex { + $0.invoicePaymentHash == hash || $0.paymentId == hash || $0.id == hash + } } - private func lockedReservations() -> [String: QuickPaySpendReservation] { - guard let data = defaults.data(forKey: Self.reservationsDefaultsKey), - let decoded = try? JSONDecoder().decode([String: QuickPaySpendReservation].self, from: data) + private func lockedLedger() -> QuickPayLedger { + guard let data = defaults.data(forKey: Self.ledgerDefaultsKey), + let decoded = try? JSONDecoder().decode(QuickPayLedger.self, from: data) else { - return [:] + return QuickPayLedger(version: 1, dayKey: "", spentCents: 0, records: []) } return decoded } - private func lockedWriteReservations(_ reservations: [String: QuickPaySpendReservation]) { + private func lockedWriteLedger(_ ledger: QuickPayLedger) { + defaults.set(try? JSONEncoder().encode(ledger), forKey: Self.ledgerDefaultsKey) + defaults.set(ledger.dayKey, forKey: Self.dayKeyDefaultsKey) + defaults.set(Int(clamping: ledger.spentCents), forKey: Self.spentCentsDefaultsKey) + var reservations: [String: QuickPaySpendReservation] = [:] + for record in ledger.records { + reservations[record.invoicePaymentHash] = QuickPaySpendReservation( + amountCents: record.amountCents, + dayKey: record.dayKey + ) + } if reservations.isEmpty { defaults.removeObject(forKey: Self.reservationsDefaultsKey) - return + } else { + defaults.set(try? JSONEncoder().encode(reservations), forKey: Self.reservationsDefaultsKey) } - - defaults.set(try? JSONEncoder().encode(reservations), forKey: Self.reservationsDefaultsKey) } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 0636f9c6b..bd6345895 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1062,19 +1062,24 @@ extension AppViewModel { break case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash - let isQuickPay = QuickPaySpendStore.shared.reservation(paymentHash: hash) != nil - || paymentHash != hash && QuickPaySpendStore.shared.reservation(paymentHash: paymentHash) != nil - QuickPaySpendStore.shared.clear(paymentHash: hash) - if paymentHash != hash { - QuickPaySpendStore.shared.clear(paymentHash: paymentHash) - } - if pendingPaymentHashes.contains(hash) { + let wasQuickPay = QuickPaySpendStore.shared.record(matching: hash) != nil + || QuickPaySpendStore.shared.record(matching: paymentHash) != nil + let outcome = QuickPaySpendStore.shared.noteTerminal( + paymentId: paymentId, + paymentHash: paymentHash, + success: true + ) + QuickPayPaymentCoordinator.shared.handleSettled(paymentId: paymentId, paymentHash: paymentHash) + let awaitingSheet = pendingPaymentHashes.contains(hash) + if awaitingSheet { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution( paymentHash: hash, success: true, - feePaidSats: isQuickPay ? (feePaidMsat ?? 0) / 1000 : nil + feePaidSats: wasQuickPay ? (feePaidMsat ?? 0) / 1000 : nil ) + } + if awaitingSheet || outcome != .none { toast( type: .lightning, title: t("wallet__toast_payment_success_title"), @@ -1084,15 +1089,18 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash - if let hash { - QuickPaySpendStore.shared.release(paymentHash: hash) - if let paymentHash, paymentHash != hash { - QuickPaySpendStore.shared.release(paymentHash: paymentHash) - } - } - if let hash, pendingPaymentHashes.contains(hash) { + let outcome = QuickPaySpendStore.shared.noteTerminal( + paymentId: paymentId, + paymentHash: paymentHash, + success: false + ) + QuickPayPaymentCoordinator.shared.handleSettled(paymentId: paymentId, paymentHash: paymentHash) + let awaitingSheet = hash.map { pendingPaymentHashes.contains($0) } ?? false + if let hash, awaitingSheet { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) + } + if awaitingSheet || outcome != .none { toast( type: .error, title: t("wallet__toast_payment_failed_title"), diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index d1cd2bc19..f16c9646d 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -887,7 +887,8 @@ class SettingsViewModel: NSObject, ObservableObject { lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], quickPaySpendDayKey: spend.dayKey, quickPaySpentCentsToday: spend.spentCents, - quickPayReservations: spend.reservations + quickPayReservations: spend.reservations, + quickPayLedger: spend.ledger ) } @@ -912,7 +913,8 @@ class SettingsViewModel: NSObject, ObservableObject { QuickPaySpendStore.shared.restoreFromBackup( dayKey: cache.quickPaySpendDayKey ?? "", spentCents: cache.quickPaySpentCentsToday ?? 0, - reservations: cache.quickPayReservations ?? [:] + reservations: cache.quickPayReservations ?? [:], + ledger: cache.quickPayLedger ) } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 5e6ec61cd..1781401e7 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -580,6 +580,7 @@ class WalletViewModel: ObservableObject { isSyncingWallet = false syncState() + QuickPayPaymentCoordinator.shared.reconcileAgainstLdk() if isPaykitUIActive { await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) @@ -879,12 +880,25 @@ class WalletViewModel: ObservableObject { ) async throws -> SettledLightningPayment { let hash = try await lightningService.send(bolt11: bolt11, sats: sats) let paymentHash = String(hash) - return try await withThrowingTaskGroup(of: SettledLightningPayment.self) { group in - group.addTask { try await self.watchSend(hash: paymentHash, afterListening: afterListening) } + afterListening?(paymentHash) + return try await waitForLightningPayment( + hash: paymentHash, + timeoutSeconds: timeoutSeconds, + onTimeout: onTimeout + ) + } + + func waitForLightningPayment( + hash: String, + timeoutSeconds: TimeInterval = 10, + onTimeout: (@MainActor (String) -> Void)? = nil + ) async throws -> SettledLightningPayment { + try await withThrowingTaskGroup(of: SettledLightningPayment.self) { group in + group.addTask { try await self.watchSend(hash: hash) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) if let onTimeout { - await MainActor.run { onTimeout(paymentHash) } + await MainActor.run { onTimeout(hash) } } throw PaymentTimeoutError.timedOut } @@ -903,10 +917,7 @@ class WalletViewModel: ObservableObject { return try await watchSend(hash: String(hash)) } - private func watchSend( - hash: String, - afterListening: (@MainActor (String) -> Void)? = nil - ) async throws -> SettledLightningPayment { + private func watchSend(hash: String) async throws -> SettledLightningPayment { let eventId = hash return try await withCheckedThrowingContinuation { continuation in @@ -930,7 +941,6 @@ class WalletViewModel: ObservableObject { } } - afterListening?(hash) syncState() } } diff --git a/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index aa406ba70..7147fb823 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -1,11 +1,9 @@ -import LDKNode import SwiftUI struct SendQuickpay: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel - @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @Binding var navigationPath: [SendRoute] @@ -40,103 +38,21 @@ struct SendQuickpay: View { .onAppear { guard !didStartPayment else { return } didStartPayment = true - Task { - await performPayment() - } - } - } - - private func performPayment() async { - guard app.beginQuickPay() else { return } - - var bolt11Invoice: String? - - do { - if let lnurlPayData = app.lnurlPayData { - wallet.sendAmountSats = lnurlPayData.minSendableSat - - bolt11Invoice = try await LnurlHelper.fetchLnurlInvoice( - data: lnurlPayData, - amountMsats: lnurlPayData.callbackAmountMsats() - ) - } else if let scannedInvoice = app.scannedLightningInvoice { - wallet.sendAmountSats = scannedInvoice.amountSatoshis - bolt11Invoice = scannedInvoice.bolt11 - } - - guard let bolt11 = bolt11Invoice else { - throw AppError(message: t("common__error_body"), debugMessage: "No Lightning invoice found") - } - - let amountSats = wallet.sendAmountSats ?? 0 - guard let reservation = try reserveDailySpend(amountSats: amountSats) else { - return - } - - var submittedHash = "" - do { - let settled = try await wallet.sendWithTimeout( - bolt11: bolt11, - sats: nil, - afterListening: { paymentHash in - submittedHash = paymentHash - QuickPaySpendStore.shared.remember(paymentHash: paymentHash, reservation: reservation) - }, - onTimeout: { paymentHash in - app.addPendingPaymentHash(paymentHash) - navigationPath.append(.pending(paymentHash: paymentHash, retryRoute: .quickpay, paymentRequest: bolt11)) - } + QuickPayPaymentCoordinator.shared.pay( + app: app, + wallet: wallet, + settings: settings, + currency: currency, + presentation: QuickPayPaymentCoordinator.Presentation( + appendRoute: { navigationPath.append($0) }, + replaceQuickPay: replaceQuickPay, + addPendingPaymentHash: { app.addPendingPaymentHash($0) }, + routingCacheResetAttempted: routingCacheResetAttempted ) - let paymentHash = String(settled.paymentHash) - QuickPaySpendStore.shared.clear(paymentHash: paymentHash) - wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( - amountSats: amountSats, - feePaidSats: settled.feePaidSats - ) - Logger.info("Quickpay payment successful: \(paymentHash)") - navigationPath.append(.success(paymentId: paymentHash)) - } catch is PaymentTimeoutError { - return - } catch { - if submittedHash.isEmpty { - QuickPaySpendStore.shared.releaseUnbound(reservation) - } else { - QuickPaySpendStore.shared.release(paymentHash: submittedHash) - } - throw error - } - } catch is PaymentTimeoutError { - return - } catch { - handlePaymentError(error, paymentRequest: bolt11Invoice) + ) } - } - - private func reserveDailySpend(amountSats: UInt64) throws -> QuickPaySpendReservation? { - let reserved = try QuickPaySpendStore.shared.tryReserve( - amountSats: amountSats, - thresholdUsd: settings.quickpayAmount, - multiplier: settings.quickpayDailyLimitMultiplier, - rates: .live(currency) - ) - - guard let reserved else { - Logger.info("Skipping QuickPay pay: daily spend reserve failed for '\(amountSats)'") - replaceQuickPay(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) - return nil + .onDisappear { + QuickPayPaymentCoordinator.shared.detach() } - - return reserved - } - - private func handlePaymentError(_ error: Error, paymentRequest: String?) { - Logger.error("Quickpay payment failed: \(error)") - - navigationPath.append(.failure(SendFailureContext( - error: error, - retryRoute: .quickpay, - routingCacheResetAttempted: routingCacheResetAttempted, - paymentRequest: paymentRequest - ))) } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 722b8cd83..574c0ee9c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -194,6 +194,7 @@ struct SendSheet: View { .onDisappear { app.contactPaymentContext = nil app.resetQuickPay() + QuickPayPaymentCoordinator.shared.detach() } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index e19ceaeee..8c18c494a 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -70,8 +70,16 @@ final class PaymentNavigationHelperTests: XCTestCase { func testSkipsQuickpayWhenDailySpendCapIsExceeded() throws { let rates = QuickPaySpendRates.live(CurrencyViewModel()) - for _ in 0 ..< 5 { - XCTAssertNotNil(try spendStore.tryReserve(amountSats: 5000, thresholdUsd: 5, multiplier: 5, rates: rates)) + for i in 0 ..< 5 { + XCTAssertNotNil( + try spendStore.reserveBound( + paymentHash: "cap\(i)", + amountSats: 5000, + thresholdUsd: 5, + multiplier: 5, + rates: rates + ) + ) } XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .confirm) @@ -79,10 +87,10 @@ final class PaymentNavigationHelperTests: XCTestCase { func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() throws { let rates = QuickPaySpendRates.live(CurrencyViewModel()) - for _ in 0 ..< 4 { - XCTAssertNotNil(try spendStore.tryReserve(amountSats: 5000, thresholdUsd: 5, multiplier: 5, rates: rates)) + for i in 0 ..< 4 { + XCTAssertNotNil(try spendStore.reserveBound(paymentHash: "under\(i)", amountSats: 5000, thresholdUsd: 5, multiplier: 5, rates: rates)) } - XCTAssertNotNil(try spendStore.tryReserve(amountSats: 4000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try spendStore.reserveBound(paymentHash: "under4", amountSats: 4000, thresholdUsd: 5, multiplier: 5, rates: rates)) XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) } diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift new file mode 100644 index 000000000..ef3e17e52 --- /dev/null +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -0,0 +1,27 @@ +@testable import Bitkit +import LDKNode +import XCTest + +@MainActor +final class QuickPayPaymentCoordinatorTests: XCTestCase { + func testClassifyDuplicatePayment() { + XCTAssertEqual( + QuickPayPaymentCoordinator.classify(NodeError.DuplicatePayment(message: "dup")), + .duplicatePayment + ) + } + + func testClassifyInvalidInvoiceAsPreDispatch() { + XCTAssertEqual( + QuickPayPaymentCoordinator.classify(NodeError.InvalidInvoice(message: "bad")), + .preDispatchRejection + ) + } + + func testClassifyPersistenceAsAmbiguous() { + XCTAssertEqual( + QuickPayPaymentCoordinator.classify(NodeError.PersistenceFailed(message: "io")), + .ambiguous + ) + } +} diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 1b933fcb8..1b97ecbde 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -39,100 +39,164 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertEqual(QuickPaySpendStore.dayKey(date: date, timeZone: timeZone), "2026-08-15") } - func testSpentCentsTodayReturnsSpendForMatchingDay() throws { - XCTAssertNotNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) + func testReserveBoundAccumulatesAndRejectsOverCap() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "a", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try sut.reserveBound(paymentHash: "b", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + for i in 2 ..< 5 { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "h\(i)", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + } + XCTAssertNil(try sut.reserveBound(paymentHash: "over", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.spentCentsToday(), 2500) + } - XCTAssertEqual(sut.spentCentsToday(), 250) + func testReserveBoundDoesNotDoubleSpendTheSameHash() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.spentCentsToday(), 500) } func testSpentCentsTodayReturnsZeroForALaterDay() throws { - XCTAssertNotNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) currentDay = "2026-08-16" XCTAssertEqual(sut.spentCentsToday(), 0) } func testSpentCentsTodayKeepsSpendOnClockRollback() throws { - XCTAssertNotNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try sut.reserveBound(paymentHash: "a", amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) currentDay = "2026-08-14" XCTAssertEqual(sut.spentCentsToday(), 250) - XCTAssertNotNil(try sut.tryReserve(amountSats: 200, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try sut.reserveBound(paymentHash: "b", amountSats: 200, thresholdUsd: 5, multiplier: 5, rates: rates)) XCTAssertEqual(sut.spentCentsToday(), 350) currentDay = "2026-08-15" XCTAssertEqual(sut.spentCentsToday(), 350) } - func testTryReserveAccumulatesOnTheSameDayAndResetsOnANewDay() throws { - XCTAssertNotNil(try sut.tryReserve(amountSats: 400, thresholdUsd: 5, multiplier: 5, rates: rates)) - XCTAssertNotNil(try sut.tryReserve(amountSats: 300, thresholdUsd: 5, multiplier: 5, rates: rates)) - XCTAssertEqual(sut.spentCentsToday(), 350) + func testNoteTerminalFailureReleasesSpend() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - currentDay = "2026-08-16" - XCTAssertNotNil(try sut.tryReserve(amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) - XCTAssertEqual(sut.spentCentsToday(), 400) + XCTAssertEqual(sut.noteTerminal(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) + XCTAssertEqual(sut.spentCentsToday(), 0) + XCTAssertNil(sut.record(matching: "abc")) } - func testTryReserveReservesUnderTheCapAndRejectsOverIt() throws { - for _ in 0 ..< 5 { - XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - } - XCTAssertNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - XCTAssertEqual(sut.spentCentsToday(), 2500) + func testNoteTerminalSuccessKeepsSpend() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + + XCTAssertEqual(sut.noteTerminal(paymentId: nil, paymentHash: "abc", success: true), .settledSuccess) + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNil(sut.record(matching: "abc")) } - func testReleaseUnboundRollsBackAReservation() throws { - let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + func testNoteTerminalMatchesPaymentIdAlias() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + sut.markSubmitted(invoicePaymentHash: "inv", paymentId: "pid") - sut.releaseUnbound(reserved) + XCTAssertEqual(sut.noteTerminal(paymentId: "pid", paymentHash: "other", success: false), .settledFailure) + XCTAssertEqual(sut.spentCentsToday(), 0) + } + + func testMarkSubmittedAfterTerminalIsNoOp() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.noteTerminal(paymentId: nil, paymentHash: "inv", success: true), .settledSuccess) + sut.markSubmitted(invoicePaymentHash: "inv", paymentId: "pid") + + XCTAssertNil(sut.record(matching: "inv")) + XCTAssertEqual(sut.spentCentsToday(), 500) + } + + func testDuplicateNoteTerminalDoesNotDecrementTwice() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertEqual(sut.noteTerminal(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) + XCTAssertEqual(sut.noteTerminal(paymentId: nil, paymentHash: "abc", success: false), .none) XCTAssertEqual(sut.spentCentsToday(), 0) } - func testReleaseUnboundOnAPriorDayDoesNotDecrementTheNewDay() throws { - let old = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + func testReleaseOnAPriorDayDoesNotDecrementTheNewDay() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "old", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) currentDay = "2026-08-16" - XCTAssertNotNil(try sut.tryReserve(amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) + XCTAssertNotNil(try sut.reserveBound(paymentHash: "new", amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.releaseUnbound(old) + sut.releaseBound(paymentHash: "old") XCTAssertEqual(sut.spentCentsToday(), 400) + XCTAssertNil(sut.record(matching: "old")) } - func testReleaseFreesPendingSpendByPaymentHash() throws { - let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.remember(paymentHash: "abc", reservation: reserved) + func testReconcileFailedReleasesRecoveredRecord() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.release(paymentHash: "abc") + sut.reconcile( + rows: [QuickPayReconcileRow(paymentId: "pid", invoicePaymentHash: "inv", isOutboundBolt11: true, status: .failed)], + liveSubmittingHashes: [] + ) XCTAssertEqual(sut.spentCentsToday(), 0) - XCTAssertNil(sut.reservation(paymentHash: "abc")) + XCTAssertNil(sut.record(matching: "inv")) } - func testClearKeepsSpendAfterSuccess() throws { - let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.remember(paymentHash: "abc", reservation: reserved) + func testReconcileSucceededClearsAndKeepsSpend() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.clear(paymentHash: "abc") + sut.reconcile( + rows: [QuickPayReconcileRow(paymentId: "pid", invoicePaymentHash: "inv", isOutboundBolt11: true, status: .succeeded)], + liveSubmittingHashes: [] + ) XCTAssertEqual(sut.spentCentsToday(), 500) - XCTAssertNil(sut.reservation(paymentHash: "abc")) + XCTAssertNil(sut.record(matching: "inv")) } - func testReleaseOnAPriorDayDoesNotDecrementTheNewDay() throws { - let old = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.remember(paymentHash: "old", reservation: old) - currentDay = "2026-08-16" - XCTAssertNotNil(try sut.tryReserve(amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) + func testReconcileAbsentOrPendingRetains() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.release(paymentHash: "old") + sut.reconcile(rows: [], liveSubmittingHashes: []) + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNotNil(sut.record(matching: "inv")) - XCTAssertEqual(sut.spentCentsToday(), 400) - XCTAssertNil(sut.reservation(paymentHash: "old")) + sut.reconcile( + rows: [QuickPayReconcileRow(paymentId: "pid", invoicePaymentHash: "inv", isOutboundBolt11: true, status: .pending)], + liveSubmittingHashes: [] + ) + XCTAssertNotNil(sut.record(matching: "inv")) + } + + func testReconcileNilLeavesLedgerUnchanged() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + + sut.reconcile(rows: nil, liveSubmittingHashes: []) + + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNotNil(sut.record(matching: "inv")) + } + + func testReconcileSkipsLiveSubmitting() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + + sut.reconcile( + rows: [QuickPayReconcileRow(paymentId: "pid", invoicePaymentHash: "inv", isOutboundBolt11: true, status: .failed)], + liveSubmittingHashes: ["inv"] + ) + + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNotNil(sut.record(matching: "inv")) + } + + func testLegacyUserDefaultsMigrateWithoutLosingSpend() throws { + defaults.set("2026-08-15", forKey: QuickPaySpendStore.dayKeyDefaultsKey) + defaults.set(500, forKey: QuickPaySpendStore.spentCentsDefaultsKey) + let reservations = ["abc": QuickPaySpendReservation(amountCents: 500, dayKey: "2026-08-15")] + try defaults.set(JSONEncoder().encode(reservations), forKey: QuickPaySpendStore.reservationsDefaultsKey) + + let migrated = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) + + XCTAssertEqual(migrated.spentCentsToday(), 500) + XCTAssertNotNil(migrated.record(matching: "abc")) } - func testAppCacheDataDecodesSpendFields() throws { - let reservation = QuickPaySpendReservation(amountCents: 500, dayKey: "2026-08-15") + func testAppCacheDataDecodesLegacySpendFields() throws { let json = """ { "hasSeenContactsIntro": false, @@ -162,40 +226,12 @@ final class QuickPaySpendStoreTests: XCTestCase { sut.restoreFromBackup( dayKey: cache.quickPaySpendDayKey ?? "", spentCents: cache.quickPaySpentCentsToday ?? 0, - reservations: cache.quickPayReservations ?? [:] + reservations: cache.quickPayReservations ?? [:], + ledger: cache.quickPayLedger ) - XCTAssertEqual(cache.quickPaySpendDayKey, "2026-08-15") - XCTAssertEqual(cache.quickPaySpentCentsToday, 500) - XCTAssertEqual(cache.quickPayReservations?["abc"], reservation) XCTAssertEqual(sut.spentCentsToday(), 500) - XCTAssertEqual(sut.reservation(paymentHash: "abc"), reservation) - } - - func testTryReserveReturnsNilWhenAmountExceedsThresholdSats() throws { - let tightRates = QuickPaySpendRates( - satsToUsdCents: { sats in Int64(sats) / 2 }, - usdToSats: { _ in 100 } - ) - - XCTAssertNil(try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: tightRates)) - XCTAssertEqual(sut.spentCentsToday(), 0) - } - - func testBackupSnapshotRoundTripsSpendAndReservations() throws { - let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.remember(paymentHash: "abc", reservation: reserved) - - let snapshot = sut.backupSnapshot() - let restored = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) - restored.restoreFromBackup( - dayKey: snapshot.dayKey, - spentCents: snapshot.spentCents, - reservations: snapshot.reservations - ) - - XCTAssertEqual(restored.spentCentsToday(), 500) - XCTAssertEqual(restored.reservation(paymentHash: "abc"), reserved) + XCTAssertNotNil(sut.record(matching: "abc")) } func testCanApplyIsTrueUnderThresholdAndCap() { @@ -205,25 +241,25 @@ final class QuickPaySpendStoreTests: XCTestCase { } func testZeroCentConversionAtFullCapDoesNotQuickPay() throws { - for _ in 0 ..< 5 { - XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + for i in 0 ..< 5 { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "h\(i)", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) } XCTAssertFalse( sut.canApply(amountSats: 7, enabled: true, thresholdUsd: 5, multiplier: 5, rates: dustRates) ) - XCTAssertNil(try sut.tryReserve(amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) + XCTAssertNil(try sut.reserveBound(paymentHash: "dust", amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) } func testZeroCentConversionReservesOneCent() throws { - let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) + let reserved = try XCTUnwrap(sut.reserveBound(paymentHash: "dust", amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) XCTAssertEqual(reserved.amountCents, 1) XCTAssertEqual(sut.spentCentsToday(), 1) } func testCanApplyIsFalseWhenDailyCapWouldBeExceeded() throws { - XCTAssertNotNil(try sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 1, rates: rates)) + XCTAssertNotNil(try sut.reserveBound(paymentHash: "a", amountSats: 1000, thresholdUsd: 5, multiplier: 1, rates: rates)) XCTAssertFalse( sut.canApply(amountSats: 1000, enabled: true, thresholdUsd: 5, multiplier: 1, rates: rates) @@ -236,25 +272,24 @@ final class QuickPaySpendStoreTests: XCTestCase { ) } - func testTryReserveFailsWithConversionErrorWhenRatesAreUnavailable() { + func testReserveBoundFailsWithConversionErrorWhenRatesAreUnavailable() { let missingRates = QuickPaySpendRates( satsToUsdCents: { _ in nil }, usdToSats: { usd in UInt64(usd * 200) } ) XCTAssertThrowsError( - try sut.tryReserve(amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: missingRates) + try sut.reserveBound(paymentHash: "abc", amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: missingRates) ) { error in XCTAssertTrue(error is QuickPayConversionError) } } - func testReservationSurvivesANewStoreInstance() throws { - let reserved = try XCTUnwrap(sut.tryReserve(amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) - sut.remember(paymentHash: "abc", reservation: reserved) + func testRecordSurvivesANewStoreInstance() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) let reloaded = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) - reloaded.release(paymentHash: "abc") + XCTAssertEqual(reloaded.noteTerminal(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) XCTAssertEqual(reloaded.spentCentsToday(), 0) } From 40112d8bfdcffe7ab9bd89cddad79542056be80c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 21 Aug 2026 17:08:07 +0200 Subject: [PATCH 24/27] refactor: drop unused QuickPay ledger fields Write only the canonical ledger. Drop unused phase/dispatch flags and collapse send-error classification to a Bool. --- Bitkit/Models/SettingsBackupConfig.swift | 4 +- .../QuickPayPaymentCoordinator.swift | 63 ++++++------------- Bitkit/Utilities/QuickPaySpendStore.swift | 49 +++------------ Bitkit/ViewModels/AppViewModel.swift | 4 +- .../QuickPayPaymentCoordinatorTests.swift | 21 ++----- 5 files changed, 34 insertions(+), 107 deletions(-) diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index fe8a8769c..b35b11f0d 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -32,9 +32,7 @@ enum SettingsBackupConfig { "highBalanceIgnoreTimestamp", "dismissedSuggestions", "lastUsedTags", - "quickPaySpendDayKey", - "quickPaySpentCentsToday", - "quickPayReservations", + "quickPayLedger", ] static let settingsKeyTypes: [String: SettingKeyType] = [ diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index ac9b15123..36690aaf1 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -1,6 +1,5 @@ import Foundation import LDKNode -import SwiftUI @MainActor final class QuickPayPaymentCoordinator { @@ -14,7 +13,6 @@ final class QuickPayPaymentCoordinator { } private struct Operation { - var dispatched = false var presentation: Presentation? } @@ -87,29 +85,21 @@ final class QuickPayPaymentCoordinator { } } - static func classify(_ error: Error) -> DispatchClass { + static func isHardReject(_ error: Error) -> Bool { if PrivatePaykitService.isDuplicatePaymentError(error) { - return .duplicatePayment + return true } guard let nodeError = error as? NodeError else { - return .ambiguous + return false } switch nodeError { - case .InvalidInvoice, .InvalidAmount, .InvalidPaymentHash, .InvalidPaymentId, .InvalidNetwork: - return .preDispatchRejection - case .DuplicatePayment: - return .duplicatePayment + case .InvalidInvoice, .InvalidAmount, .InvalidPaymentHash, .InvalidPaymentId, .InvalidNetwork, .DuplicatePayment: + return true default: - return .ambiguous + return false } } - enum DispatchClass { - case preDispatchRejection - case duplicatePayment - case ambiguous - } - private func run( generation: UUID, app: AppViewModel, @@ -156,57 +146,48 @@ final class QuickPayPaymentCoordinator { return } - if store.hasOpenRecord(paymentHash: invoiceHash) { - operations[invoiceHash] = Operation(dispatched: true, presentation: presentation) + if store.record(matching: invoiceHash) != nil { + operations[invoiceHash] = Operation(presentation: presentation) return } guard generation == self.generation else { return } let amountSats = wallet.sendAmountSats ?? 0 - let reserved: QuickPayLedgerRecord? do { - reserved = try store.reserveBound( + guard try store.reserveBound( paymentHash: invoiceHash, amountSats: amountSats, thresholdUsd: settings.quickpayAmount, multiplier: settings.quickpayDailyLimitMultiplier, rates: .live(currency) - ) + ) != nil else { + presentation.replaceQuickPay(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) + return + } } catch { fail(presentation, error: error, bolt11: bolt11) return } - guard let reserved else { - presentation.replaceQuickPay(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) - return - } - guard generation == self.generation else { store.releaseBound(paymentHash: invoiceHash) return } - operations[invoiceHash] = Operation(dispatched: false, presentation: presentation) + operations[invoiceHash] = Operation(presentation: presentation) do { let paymentId = try await sendBolt11(bolt11) store.markSubmitted(invoicePaymentHash: invoiceHash, paymentId: paymentId) - if var op = operations[invoiceHash] { - op.dispatched = true - operations[invoiceHash] = op - if paymentId != invoiceHash { - operations[paymentId] = op - } + if let op = operations[invoiceHash], paymentId != invoiceHash { + operations[paymentId] = op } } catch { await handleDispatchError(error, invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) return } - _ = reserved - guard let attached = operations[invoiceHash]?.presentation else { return } do { @@ -240,18 +221,12 @@ final class QuickPayPaymentCoordinator { presentation: Presentation ) async { let attached = operations[invoiceHash]?.presentation - switch Self.classify(error) { - case .duplicatePayment, .preDispatchRejection: + if Self.isHardReject(error) { store.releaseBound(paymentHash: invoiceHash) operations.removeValue(forKey: invoiceHash) - case .ambiguous: + } else { await store.reconcile(rows: listRows(), liveSubmittingHashes: []) - if store.record(matching: invoiceHash) != nil { - if var op = operations[invoiceHash] { - op.dispatched = true - operations[invoiceHash] = op - } - } else { + if store.record(matching: invoiceHash) == nil { operations.removeValue(forKey: invoiceHash) } } diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index ed626230b..22fcd57f7 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -28,18 +28,12 @@ struct QuickPaySpendRates { } } -enum QuickPayRecordPhase: String, Codable { - case submitting - case submitted -} - struct QuickPayLedgerRecord: Codable, Equatable { let id: String let amountCents: Int64 let dayKey: String let invoicePaymentHash: String var paymentId: String? - var phase: QuickPayRecordPhase } struct QuickPayLedger: Codable, Equatable { @@ -160,12 +154,6 @@ final class QuickPaySpendStore: @unchecked Sendable { return false } - func hasOpenRecord(paymentHash: String) -> Bool { - lock.lock() - defer { lock.unlock() } - return lockedRecord(matching: paymentHash) != nil - } - func record(matching hash: String) -> QuickPayLedgerRecord? { lock.lock() defer { lock.unlock() } @@ -210,8 +198,7 @@ final class QuickPaySpendStore: @unchecked Sendable { amountCents: amountCents, dayKey: spend.dayKey, invoicePaymentHash: paymentHash, - paymentId: nil, - phase: .submitting + paymentId: nil ) ledger.dayKey = spend.dayKey ledger.spentCents = total @@ -227,7 +214,6 @@ final class QuickPaySpendStore: @unchecked Sendable { var ledger = lockedLedger() guard let index = lockedRecordIndex(in: ledger, matching: invoicePaymentHash) else { return } ledger.records[index].paymentId = paymentId - ledger.records[index].phase = .submitted lockedWriteLedger(ledger) } @@ -312,11 +298,10 @@ final class QuickPaySpendStore: @unchecked Sendable { let ledger = lockedLedger() var reservations: [String: QuickPaySpendReservation] = [:] for record in ledger.records { - let value = QuickPaySpendReservation(amountCents: record.amountCents, dayKey: record.dayKey) - reservations[record.invoicePaymentHash] = value - if let paymentId = record.paymentId, paymentId != record.invoicePaymentHash { - reservations[paymentId] = value - } + reservations[record.invoicePaymentHash] = QuickPaySpendReservation( + amountCents: record.amountCents, + dayKey: record.dayKey + ) } return (ledger.dayKey, ledger.spentCents, reservations, ledger) } @@ -365,21 +350,15 @@ final class QuickPaySpendStore: @unchecked Sendable { spentCents: Int64, reservations: [String: QuickPaySpendReservation] ) -> QuickPayLedger { - var seen: Set = [] var records: [QuickPayLedgerRecord] = [] for (hash, reservation) in reservations { - if seen.contains(hash) { - continue - } - seen.insert(hash) records.append( QuickPayLedgerRecord( id: UUID().uuidString, amountCents: reservation.amountCents, dayKey: reservation.dayKey, invoicePaymentHash: hash, - paymentId: nil, - phase: .submitted + paymentId: nil ) ) } @@ -413,7 +392,7 @@ final class QuickPaySpendStore: @unchecked Sendable { private func lockedRecordIndex(in ledger: QuickPayLedger, matching hash: String) -> Int? { ledger.records.firstIndex { - $0.invoicePaymentHash == hash || $0.paymentId == hash || $0.id == hash + $0.invoicePaymentHash == hash || $0.paymentId == hash } } @@ -428,19 +407,5 @@ final class QuickPaySpendStore: @unchecked Sendable { private func lockedWriteLedger(_ ledger: QuickPayLedger) { defaults.set(try? JSONEncoder().encode(ledger), forKey: Self.ledgerDefaultsKey) - defaults.set(ledger.dayKey, forKey: Self.dayKeyDefaultsKey) - defaults.set(Int(clamping: ledger.spentCents), forKey: Self.spentCentsDefaultsKey) - var reservations: [String: QuickPaySpendReservation] = [:] - for record in ledger.records { - reservations[record.invoicePaymentHash] = QuickPaySpendReservation( - amountCents: record.amountCents, - dayKey: record.dayKey - ) - } - if reservations.isEmpty { - defaults.removeObject(forKey: Self.reservationsDefaultsKey) - } else { - defaults.set(try? JSONEncoder().encode(reservations), forKey: Self.reservationsDefaultsKey) - } } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index bd6345895..963bca1aa 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1062,8 +1062,6 @@ extension AppViewModel { break case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash - let wasQuickPay = QuickPaySpendStore.shared.record(matching: hash) != nil - || QuickPaySpendStore.shared.record(matching: paymentHash) != nil let outcome = QuickPaySpendStore.shared.noteTerminal( paymentId: paymentId, paymentHash: paymentHash, @@ -1076,7 +1074,7 @@ extension AppViewModel { sendSheetPendingResolution = SendSheetPendingResolution( paymentHash: hash, success: true, - feePaidSats: wasQuickPay ? (feePaidMsat ?? 0) / 1000 : nil + feePaidSats: outcome != .none ? (feePaidMsat ?? 0) / 1000 : nil ) } if awaitingSheet || outcome != .none { diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index ef3e17e52..f164430d9 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -4,24 +4,15 @@ import XCTest @MainActor final class QuickPayPaymentCoordinatorTests: XCTestCase { - func testClassifyDuplicatePayment() { - XCTAssertEqual( - QuickPayPaymentCoordinator.classify(NodeError.DuplicatePayment(message: "dup")), - .duplicatePayment - ) + func testDuplicatePaymentIsHardReject() { + XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment(message: "dup"))) } - func testClassifyInvalidInvoiceAsPreDispatch() { - XCTAssertEqual( - QuickPayPaymentCoordinator.classify(NodeError.InvalidInvoice(message: "bad")), - .preDispatchRejection - ) + func testInvalidInvoiceIsHardReject() { + XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.InvalidInvoice(message: "bad"))) } - func testClassifyPersistenceAsAmbiguous() { - XCTAssertEqual( - QuickPayPaymentCoordinator.classify(NodeError.PersistenceFailed(message: "io")), - .ambiguous - ) + func testPersistenceIsNotHardReject() { + XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.PersistenceFailed(message: "io"))) } } From 4a4aa9aa3461a52d9ba3dac0a9c8307b458f2e66 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 21 Aug 2026 23:02:47 +0200 Subject: [PATCH 25/27] refactor: drop intra-PR QuickPay spend migrate Restore from the Android three-field snapshot only. Local store stays a ledger. Drop unused record id and ledger version. --- Bitkit/Models/BackupPayloads.swift | 8 +-- .../QuickPayPaymentCoordinator.swift | 34 +++++------ Bitkit/Utilities/QuickPaySpendStore.swift | 58 ++----------------- Bitkit/ViewModels/SettingsViewModel.swift | 6 +- BitkitTests/QuickPaySpendStoreTests.swift | 15 +---- 5 files changed, 23 insertions(+), 98 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 9304141e7..2d885972f 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -52,7 +52,6 @@ struct AppCacheData: Codable { let quickPaySpendDayKey: String? let quickPaySpentCentsToday: Int64? let quickPayReservations: [String: QuickPaySpendReservation]? - let quickPayLedger: QuickPayLedger? init( hasSeenContactsIntro: Bool, @@ -73,8 +72,7 @@ struct AppCacheData: Codable { lastUsedTags: [String], quickPaySpendDayKey: String? = nil, quickPaySpentCentsToday: Int64? = nil, - quickPayReservations: [String: QuickPaySpendReservation]? = nil, - quickPayLedger: QuickPayLedger? = nil + quickPayReservations: [String: QuickPaySpendReservation]? = nil ) { self.hasSeenContactsIntro = hasSeenContactsIntro self.hasSeenProfileIntro = hasSeenProfileIntro @@ -95,7 +93,6 @@ struct AppCacheData: Codable { self.quickPaySpendDayKey = quickPaySpendDayKey self.quickPaySpentCentsToday = quickPaySpentCentsToday self.quickPayReservations = quickPayReservations - self.quickPayLedger = quickPayLedger } init(from decoder: Decoder) throws { @@ -119,7 +116,6 @@ struct AppCacheData: Codable { quickPaySpendDayKey = try c.decodeIfPresent(String.self, forKey: .quickPaySpendDayKey) quickPaySpentCentsToday = try c.decodeIfPresent(Int64.self, forKey: .quickPaySpentCentsToday) quickPayReservations = try c.decodeIfPresent([String: QuickPaySpendReservation].self, forKey: .quickPayReservations) - quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger) } private enum CodingKeys: String, CodingKey { @@ -128,7 +124,7 @@ struct AppCacheData: Codable { case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp case dismissedSuggestions, lastUsedTags - case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations, quickPayLedger + case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations } } diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index 36690aaf1..96f77e5d6 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -12,15 +12,11 @@ final class QuickPayPaymentCoordinator { var routingCacheResetAttempted: Bool } - private struct Operation { - var presentation: Presentation? - } - private let store: QuickPaySpendStore private let sendBolt11: (String) async throws -> String private let listRows: () async -> [QuickPayReconcileRow]? - private var operations: [String: Operation] = [:] + private var operations: [String: Presentation?] = [:] private var generation = UUID() var liveSubmittingHashes: Set { @@ -43,11 +39,8 @@ final class QuickPayPaymentCoordinator { func detach() { generation = UUID() - for hash in operations.keys { - if var op = operations[hash] { - op.presentation = nil - operations[hash] = op - } + for hash in Array(operations.keys) { + operations[hash] = Optional.none } } @@ -140,14 +133,13 @@ final class QuickPayPaymentCoordinator { return } - if var existing = operations[invoiceHash] { - existing.presentation = presentation - operations[invoiceHash] = existing + if operations[invoiceHash] != nil { + operations[invoiceHash] = presentation return } if store.record(matching: invoiceHash) != nil { - operations[invoiceHash] = Operation(presentation: presentation) + operations[invoiceHash] = presentation return } @@ -175,27 +167,27 @@ final class QuickPayPaymentCoordinator { return } - operations[invoiceHash] = Operation(presentation: presentation) + operations[invoiceHash] = presentation do { let paymentId = try await sendBolt11(bolt11) store.markSubmitted(invoicePaymentHash: invoiceHash, paymentId: paymentId) - if let op = operations[invoiceHash], paymentId != invoiceHash { - operations[paymentId] = op + if operations[invoiceHash] != nil, paymentId != invoiceHash { + operations[paymentId] = operations[invoiceHash] } } catch { await handleDispatchError(error, invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) return } - guard let attached = operations[invoiceHash]?.presentation else { return } + guard let attached = operations[invoiceHash] ?? nil else { return } do { let settled = try await wallet.waitForLightningPayment(hash: invoiceHash) { hash in attached.addPendingPaymentHash(hash) attached.appendRoute(.pending(paymentHash: hash, retryRoute: .quickpay, paymentRequest: bolt11)) } - operations[invoiceHash]?.presentation?.appendRoute(.success(paymentId: String(settled.paymentHash))) + operations[invoiceHash]??.appendRoute(.success(paymentId: String(settled.paymentHash))) if let amountSats = wallet.sendAmountSats { wallet.sendAmountSats = QuickPayLimits.amountWithFeeSats( amountSats: amountSats, @@ -205,7 +197,7 @@ final class QuickPayPaymentCoordinator { } catch is PaymentTimeoutError { return } catch { - operations[invoiceHash]?.presentation?.appendRoute(.failure(SendFailureContext( + operations[invoiceHash]??.appendRoute(.failure(SendFailureContext( error: error, retryRoute: .quickpay, routingCacheResetAttempted: attached.routingCacheResetAttempted, @@ -220,7 +212,7 @@ final class QuickPayPaymentCoordinator { bolt11: String, presentation: Presentation ) async { - let attached = operations[invoiceHash]?.presentation + let attached = operations[invoiceHash] ?? nil if Self.isHardReject(error) { store.releaseBound(paymentHash: invoiceHash) operations.removeValue(forKey: invoiceHash) diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 22fcd57f7..d59f44b3e 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -29,7 +29,6 @@ struct QuickPaySpendRates { } struct QuickPayLedgerRecord: Codable, Equatable { - let id: String let amountCents: Int64 let dayKey: String let invoicePaymentHash: String @@ -37,7 +36,6 @@ struct QuickPayLedgerRecord: Codable, Equatable { } struct QuickPayLedger: Codable, Equatable { - var version: Int var dayKey: String var spentCents: Int64 var records: [QuickPayLedgerRecord] @@ -94,9 +92,6 @@ final class QuickPaySpendStore: @unchecked Sendable { static let shared = QuickPaySpendStore() static let ledgerDefaultsKey = "quickPayLedger" - static let dayKeyDefaultsKey = "quickPaySpendDayKey" - static let spentCentsDefaultsKey = "quickPaySpentCentsToday" - static let reservationsDefaultsKey = "quickPayReservations" private let defaults: UserDefaults private let lock = NSLock() @@ -105,9 +100,6 @@ final class QuickPaySpendStore: @unchecked Sendable { init(defaults: UserDefaults = .standard, dayKey: @escaping () -> String = { QuickPaySpendStore.dayKey() }) { self.defaults = defaults dayKeyProvider = dayKey - lock.lock() - migrateLegacyIfNeededLocked() - lock.unlock() } static func dayKey(date: Date = Date(), timeZone: TimeZone = .current) -> String { @@ -194,7 +186,6 @@ final class QuickPaySpendStore: @unchecked Sendable { var ledger = lockedLedger() lockedPrune(ledger: &ledger, currentDay: spend.dayKey) let record = QuickPayLedgerRecord( - id: UUID().uuidString, amountCents: amountCents, dayKey: spend.dayKey, invoicePaymentHash: paymentHash, @@ -290,8 +281,7 @@ final class QuickPaySpendStore: @unchecked Sendable { func backupSnapshot() -> ( dayKey: String, spentCents: Int64, - reservations: [String: QuickPaySpendReservation], - ledger: QuickPayLedger + reservations: [String: QuickPaySpendReservation] ) { lock.lock() defer { lock.unlock() } @@ -303,58 +293,20 @@ final class QuickPaySpendStore: @unchecked Sendable { dayKey: record.dayKey ) } - return (ledger.dayKey, ledger.spentCents, reservations, ledger) + return (ledger.dayKey, ledger.spentCents, reservations) } func restoreFromBackup( dayKey: String, spentCents: Int64, - reservations: [String: QuickPaySpendReservation], - ledger: QuickPayLedger? = nil + reservations: [String: QuickPaySpendReservation] ) { lock.lock() defer { lock.unlock() } - if let ledger, ledger.version >= 1 { - var restored = ledger - lockedPrune(ledger: &restored, currentDay: dayKeyProvider()) - lockedWriteLedger(restored) - return - } - lockedWriteLedger(Self.ledgerFromLegacy(dayKey: dayKey, spentCents: spentCents, reservations: reservations)) - } - - private func migrateLegacyIfNeededLocked() { - if defaults.data(forKey: Self.ledgerDefaultsKey) != nil { - return - } - let dayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? "" - let spentCents = Int64(max(defaults.integer(forKey: Self.spentCentsDefaultsKey), 0)) - let reservations: [String: QuickPaySpendReservation] = if let data = defaults.data(forKey: Self.reservationsDefaultsKey), - let decoded = try? JSONDecoder().decode( - [String: QuickPaySpendReservation].self, - from: data - ) - { - decoded - } else { - [:] - } - if dayKey.isEmpty, spentCents == 0, reservations.isEmpty { - return - } - lockedWriteLedger(Self.ledgerFromLegacy(dayKey: dayKey, spentCents: spentCents, reservations: reservations)) - } - - private static func ledgerFromLegacy( - dayKey: String, - spentCents: Int64, - reservations: [String: QuickPaySpendReservation] - ) -> QuickPayLedger { var records: [QuickPayLedgerRecord] = [] for (hash, reservation) in reservations { records.append( QuickPayLedgerRecord( - id: UUID().uuidString, amountCents: reservation.amountCents, dayKey: reservation.dayKey, invoicePaymentHash: hash, @@ -362,7 +314,7 @@ final class QuickPaySpendStore: @unchecked Sendable { ) ) } - return QuickPayLedger(version: 1, dayKey: dayKey, spentCents: max(spentCents, 0), records: records) + lockedWriteLedger(QuickPayLedger(dayKey: dayKey, spentCents: max(spentCents, 0), records: records)) } private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentCents: Int64) { @@ -400,7 +352,7 @@ final class QuickPaySpendStore: @unchecked Sendable { guard let data = defaults.data(forKey: Self.ledgerDefaultsKey), let decoded = try? JSONDecoder().decode(QuickPayLedger.self, from: data) else { - return QuickPayLedger(version: 1, dayKey: "", spentCents: 0, records: []) + return QuickPayLedger(dayKey: "", spentCents: 0, records: []) } return decoded } diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index f16c9646d..d1cd2bc19 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -887,8 +887,7 @@ class SettingsViewModel: NSObject, ObservableObject { lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], quickPaySpendDayKey: spend.dayKey, quickPaySpentCentsToday: spend.spentCents, - quickPayReservations: spend.reservations, - quickPayLedger: spend.ledger + quickPayReservations: spend.reservations ) } @@ -913,8 +912,7 @@ class SettingsViewModel: NSObject, ObservableObject { QuickPaySpendStore.shared.restoreFromBackup( dayKey: cache.quickPaySpendDayKey ?? "", spentCents: cache.quickPaySpentCentsToday ?? 0, - reservations: cache.quickPayReservations ?? [:], - ledger: cache.quickPayLedger + reservations: cache.quickPayReservations ?? [:] ) } } diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index 1b97ecbde..e24e0ab62 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -184,18 +184,6 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertNotNil(sut.record(matching: "inv")) } - func testLegacyUserDefaultsMigrateWithoutLosingSpend() throws { - defaults.set("2026-08-15", forKey: QuickPaySpendStore.dayKeyDefaultsKey) - defaults.set(500, forKey: QuickPaySpendStore.spentCentsDefaultsKey) - let reservations = ["abc": QuickPaySpendReservation(amountCents: 500, dayKey: "2026-08-15")] - try defaults.set(JSONEncoder().encode(reservations), forKey: QuickPaySpendStore.reservationsDefaultsKey) - - let migrated = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) - - XCTAssertEqual(migrated.spentCentsToday(), 500) - XCTAssertNotNil(migrated.record(matching: "abc")) - } - func testAppCacheDataDecodesLegacySpendFields() throws { let json = """ { @@ -226,8 +214,7 @@ final class QuickPaySpendStoreTests: XCTestCase { sut.restoreFromBackup( dayKey: cache.quickPaySpendDayKey ?? "", spentCents: cache.quickPaySpentCentsToday ?? 0, - reservations: cache.quickPayReservations ?? [:], - ledger: cache.quickPayLedger + reservations: cache.quickPayReservations ?? [:] ) XCTAssertEqual(sut.spentCentsToday(), 500) From 7f57bc64c89527e54f160f52c2f17fe4b12e203c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 03:47:18 +0200 Subject: [PATCH 26/27] fix: map QuickPay threshold to Android backup key Write and restore quickPayAmount so the per-tx threshold round-trips with the daily multiplier. --- Bitkit/Models/SettingsBackupConfig.swift | 1 + BitkitTests/AddressTypeSettingsTests.swift | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift index b35b11f0d..591c04979 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -67,6 +67,7 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", + "quickpayAmount": "quickPayAmount", "quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier", "enableNotifications": "notificationsGranted", // Note: PIN settings are intentionally NOT backed up for security diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index c4c3a2e14..54b2b215c 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -253,6 +253,7 @@ final class AddressTypeSettingsTests: XCTestCase { settings.addressTypesToMonitor = [.nativeSegwit, .taproot, .legacy] settings.hideBalance = true settings.enableQuickpay = true + settings.quickpayAmount = 1 settings.quickpayDailyLimitMultiplier = 10 UserDefaults.standard.synchronize() @@ -275,11 +276,20 @@ final class AddressTypeSettingsTests: XCTestCase { "hideBalance should survive full backup→reset→restore cycle") XCTAssertEqual(settings.enableQuickpay, true, "enableQuickpay should survive full backup→reset→restore cycle") + XCTAssertEqual(settings.quickpayAmount, 1, + "quickpayAmount should survive full backup→reset→restore cycle") XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 10, "quickpayDailyLimitMultiplier should survive full backup→reset→restore cycle") + XCTAssertEqual(backupDict["quickPayAmount"] as? Int, 1) XCTAssertEqual(backupDict["quickPayDailyLimitMultiplier"] as? Int, 10) } + func testRestoresQuickpayAmountFromAndroidKey() { + settings.restoreSettingsDictionary(["quickPayAmount": 1]) + + XCTAssertEqual(settings.quickpayAmount, 1) + } + func testRestoresDailyLimitMultiplierFromAndroidKey() { settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 3]) From 111d828967429e58030ec7184f571f7e80b48fe7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 03:47:18 +0200 Subject: [PATCH 27/27] refactor: drop Paykit from QuickPay hard-reject Duplicate is NodeError.DuplicatePayment only. --- Bitkit/Utilities/QuickPayPaymentCoordinator.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index 96f77e5d6..8ba39ef76 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -79,9 +79,6 @@ final class QuickPayPaymentCoordinator { } static func isHardReject(_ error: Error) -> Bool { - if PrivatePaykitService.isDuplicatePaymentError(error) { - return true - } guard let nodeError = error as? NodeError else { return false }