From ea2cccddf669ff55e1472d3ea9876d7ae0a17133 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:26:43 +0200 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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 } From 5c8032e83b0f607082c6163e646da7c98ec536c8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 04:18:40 +0200 Subject: [PATCH 28/30] fix: resume QuickPay UI when send settles off-sheet Open Pending for a leftover ledger row instead of sitting on the loader. If noteTerminal already cleared the row after send, push Success. Ambiguous dispatch errors go Pending while the row remains, Failure when it is gone. --- .../QuickPayPaymentCoordinator.swift | 46 ++-- .../QuickPayPaymentCoordinatorTests.swift | 213 ++++++++++++++++++ 2 files changed, 244 insertions(+), 15 deletions(-) diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index 8ba39ef76..ec452fde1 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -130,13 +130,10 @@ final class QuickPayPaymentCoordinator { return } - if operations[invoiceHash] != nil { - operations[invoiceHash] = presentation - return - } - - if store.record(matching: invoiceHash) != nil { - operations[invoiceHash] = presentation + let alreadyOpen = operations[invoiceHash] != nil || store.record(matching: invoiceHash) != nil + operations[invoiceHash] = presentation + if alreadyOpen { + resumePending(invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) return } @@ -177,7 +174,12 @@ final class QuickPayPaymentCoordinator { return } - guard let attached = operations[invoiceHash] ?? nil else { return } + if store.record(matching: invoiceHash) == nil { + presentation.appendRoute(.success(paymentId: invoiceHash)) + return + } + + let attached = (operations[invoiceHash] ?? nil) ?? presentation do { let settled = try await wallet.waitForLightningPayment(hash: invoiceHash) { hash in @@ -209,18 +211,27 @@ final class QuickPayPaymentCoordinator { bolt11: String, presentation: Presentation ) async { - let attached = operations[invoiceHash] ?? nil + let attached = (operations[invoiceHash] ?? nil) ?? presentation if Self.isHardReject(error) { store.releaseBound(paymentHash: invoiceHash) operations.removeValue(forKey: invoiceHash) - } else { - await store.reconcile(rows: listRows(), liveSubmittingHashes: []) - if store.record(matching: invoiceHash) == nil { - operations.removeValue(forKey: invoiceHash) - } + attached.appendRoute(.failure(SendFailureContext( + error: error, + retryRoute: .quickpay, + routingCacheResetAttempted: presentation.routingCacheResetAttempted, + paymentRequest: bolt11 + ))) + return + } + + await store.reconcile(rows: listRows(), liveSubmittingHashes: []) + if store.record(matching: invoiceHash) != nil { + resumePending(invoiceHash: invoiceHash, bolt11: bolt11, presentation: attached) + return } - attached?.appendRoute(.failure(SendFailureContext( + operations.removeValue(forKey: invoiceHash) + attached.appendRoute(.failure(SendFailureContext( error: error, retryRoute: .quickpay, routingCacheResetAttempted: presentation.routingCacheResetAttempted, @@ -228,6 +239,11 @@ final class QuickPayPaymentCoordinator { ))) } + private func resumePending(invoiceHash: String, bolt11: String, presentation: Presentation) { + presentation.addPendingPaymentHash(invoiceHash) + presentation.appendRoute(.pending(paymentHash: invoiceHash, retryRoute: .quickpay, paymentRequest: bolt11)) + } + private func fail(_ presentation: Presentation, error: Error, bolt11: String?) { presentation.appendRoute(.failure(SendFailureContext( error: error, diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index f164430d9..402342b90 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -1,9 +1,57 @@ @testable import Bitkit +import BitkitCore import LDKNode import XCTest @MainActor final class QuickPayPaymentCoordinatorTests: XCTestCase { + private let settings = SettingsViewModel.shared + private var originalEnableQuickpay = false + private var originalQuickpayAmount: Double = 0 + private var originalQuickpayDailyLimitMultiplier: Double = 0 + private var originalCachedRates: Data? + private var defaults: UserDefaults! + private var suiteName: String! + private var store: QuickPaySpendStore! + private let rates = QuickPaySpendRates( + satsToUsdCents: { sats in Int64(sats) / 2 }, + usdToSats: { usd in UInt64(usd * 200) } + ) + + override func setUp() { + super.setUp() + originalEnableQuickpay = settings.enableQuickpay + originalQuickpayAmount = settings.quickpayAmount + originalQuickpayDailyLimitMultiplier = settings.quickpayDailyLimitMultiplier + originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + suiteName = "QuickPayPaymentCoordinatorTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + store = QuickPaySpendStore(defaults: defaults, dayKey: { "2026-08-15" }) + 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 + if let originalCachedRates { + UserDefaults.standard.set(originalCachedRates, forKey: "cached_fx_rates") + } else { + UserDefaults.standard.removeObject(forKey: "cached_fx_rates") + } + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + store = nil + super.tearDown() + } + func testDuplicatePaymentIsHardReject() { XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment(message: "dup"))) } @@ -15,4 +63,169 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { func testPersistenceIsNotHardReject() { XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.PersistenceFailed(message: "io"))) } + + func testLeftoverRecordGoesPendingWithoutSending() async throws { + let invoiceHash = try Self.invoiceHash + var sent = false + XCTAssertNotNil( + try store.reserveBound( + paymentHash: invoiceHash, + amountSats: 1000, + thresholdUsd: 5, + multiplier: 5, + rates: rates + ) + ) + + let route = await firstRoute( + sendBolt11: { _ in + sent = true + return invoiceHash + } + ) + + XCTAssertFalse(sent) + guard case let .pending(paymentHash, retryRoute, _) = route else { + return XCTFail("Expected pending, got \(String(describing: route))") + } + XCTAssertEqual(paymentHash, invoiceHash) + XCTAssertEqual(retryRoute, .quickpay) + XCTAssertNotNil(store.record(matching: invoiceHash)) + } + + func testSendAfterTerminalGoesToSuccess() async throws { + let invoiceHash = try Self.invoiceHash + let route = await firstRoute( + sendBolt11: { [store] _ in + store?.noteTerminal(paymentId: nil, paymentHash: invoiceHash, success: true) + return invoiceHash + } + ) + + guard case let .success(paymentId) = route else { + return XCTFail("Expected success, got \(String(describing: route))") + } + XCTAssertEqual(paymentId, invoiceHash) + XCTAssertNil(store.record(matching: invoiceHash)) + } + + func testAmbiguousDispatchWithOpenRecordGoesPending() async throws { + let invoiceHash = try Self.invoiceHash + let route = await firstRoute( + sendBolt11: { _ in + throw NodeError.PersistenceFailed(message: "io") + } + ) + + guard case let .pending(paymentHash, retryRoute, _) = route else { + return XCTFail("Expected pending, got \(String(describing: route))") + } + XCTAssertEqual(paymentHash, invoiceHash) + XCTAssertEqual(retryRoute, .quickpay) + XCTAssertNotNil(store.record(matching: invoiceHash)) + } + + func testAmbiguousDispatchWithFailedRowGoesToFailure() async throws { + let invoiceHash = try Self.invoiceHash + let route = await firstRoute( + sendBolt11: { _ in + throw NodeError.PersistenceFailed(message: "io") + }, + listRows: { + [ + QuickPayReconcileRow( + paymentId: "pid", + invoicePaymentHash: invoiceHash, + isOutboundBolt11: true, + status: .failed + ), + ] + } + ) + + guard case .failure = route else { + return XCTFail("Expected failure, got \(String(describing: route))") + } + XCTAssertNil(store.record(matching: invoiceHash)) + } + + func testDuplicateDispatchGoesToFailureAndReleases() async throws { + let invoiceHash = try Self.invoiceHash + let route = await firstRoute( + sendBolt11: { _ in + throw NodeError.DuplicatePayment(message: "dup") + } + ) + + guard case .failure = route else { + return XCTFail("Expected failure, got \(String(describing: route))") + } + XCTAssertNil(store.record(matching: invoiceHash)) + XCTAssertEqual(store.spentCentsToday(), 0) + } + + private func firstRoute( + sendBolt11: @escaping (String) async throws -> String, + listRows: @escaping () async -> [QuickPayReconcileRow]? = { [] } + ) async -> SendRoute? { + let coordinator = QuickPayPaymentCoordinator(store: store, sendBolt11: sendBolt11, listRows: listRows) + var route: SendRoute? + let exp = expectation(description: "route") + coordinator.pay( + app: appWithInvoice, + wallet: WalletViewModel(), + settings: settings, + currency: CurrencyViewModel(), + presentation: QuickPayPaymentCoordinator.Presentation( + appendRoute: { + route = $0 + exp.fulfill() + }, + replaceQuickPay: { _ in }, + addPendingPaymentHash: { _ in }, + routingCacheResetAttempted: false + ) + ) + await fulfillment(of: [exp], timeout: 2) + return route + } + + private var appWithInvoice: AppViewModel { + let app = AppViewModel() + app.scannedLightningInvoice = LightningInvoice( + bolt11: Self.regtestBolt11, + 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 + ) + } + + private static var invoiceHash: String { + get throws { + try String(Bolt11Invoice.fromStr(invoiceStr: regtestBolt11).paymentHash()) + } + } + + private static let regtestBolt11 = + "lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt" } From 77c47257d8d8b0b384e73e9bf7b2700fc0a20651 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:35:37 +0200 Subject: [PATCH 29/30] fix: keep quickpay spend on dup pay --- .../QuickPayPaymentCoordinator.swift | 65 +++++-- Bitkit/Utilities/QuickPaySpendStore.swift | 50 +++-- Bitkit/ViewModels/AppViewModel.swift | 10 +- .../QuickPayPaymentCoordinatorTests.swift | 183 ++++++++++++++++-- BitkitTests/QuickPaySpendStoreTests.swift | 26 +-- 5 files changed, 281 insertions(+), 53 deletions(-) diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index ec452fde1..e5d495a9a 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -51,11 +51,10 @@ final class QuickPayPaymentCoordinator { currency: CurrencyViewModel, presentation: Presentation ) { - let generation = UUID() - self.generation = generation + let capturedGeneration = generation Task { await run( - generation: generation, + generation: capturedGeneration, app: app, wallet: wallet, settings: settings, @@ -83,7 +82,7 @@ final class QuickPayPaymentCoordinator { return false } switch nodeError { - case .InvalidInvoice, .InvalidAmount, .InvalidPaymentHash, .InvalidPaymentId, .InvalidNetwork, .DuplicatePayment: + case .InvalidInvoice, .InvalidAmount, .InvalidPaymentHash, .InvalidPaymentId, .InvalidNetwork: return true default: return false @@ -130,16 +129,21 @@ final class QuickPayPaymentCoordinator { return } - let alreadyOpen = operations[invoiceHash] != nil || store.record(matching: invoiceHash) != nil - operations[invoiceHash] = presentation - if alreadyOpen { - resumePending(invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) + if (operations[invoiceHash] ?? nil) != nil { + operations[invoiceHash] = presentation + return + } + + if store.record(matching: invoiceHash) != nil { + operations[invoiceHash] = presentation + await settleRecovered(invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) return } guard generation == self.generation else { return } let amountSats = wallet.sendAmountSats ?? 0 + operations[invoiceHash] = presentation do { guard try store.reserveBound( paymentHash: invoiceHash, @@ -148,21 +152,26 @@ final class QuickPayPaymentCoordinator { multiplier: settings.quickpayDailyLimitMultiplier, rates: .live(currency) ) != nil else { + if store.record(matching: invoiceHash) != nil { + await settleRecovered(invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) + return + } + operations.removeValue(forKey: invoiceHash) presentation.replaceQuickPay(PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: app)) return } } catch { + operations.removeValue(forKey: invoiceHash) fail(presentation, error: error, bolt11: bolt11) return } guard generation == self.generation else { store.releaseBound(paymentHash: invoiceHash) + operations.removeValue(forKey: invoiceHash) return } - operations[invoiceHash] = presentation - do { let paymentId = try await sendBolt11(bolt11) store.markSubmitted(invoicePaymentHash: invoiceHash, paymentId: paymentId) @@ -224,13 +233,18 @@ final class QuickPayPaymentCoordinator { return } - await store.reconcile(rows: listRows(), liveSubmittingHashes: []) + let rows = await listRows() + store.reconcile(rows: rows, liveSubmittingHashes: []) if store.record(matching: invoiceHash) != nil { resumePending(invoiceHash: invoiceHash, bolt11: bolt11, presentation: attached) return } operations.removeValue(forKey: invoiceHash) + if ldkSucceeded(invoiceHash: invoiceHash, rows: rows) { + attached.appendRoute(.success(paymentId: invoiceHash)) + return + } attached.appendRoute(.failure(SendFailureContext( error: error, retryRoute: .quickpay, @@ -239,6 +253,35 @@ final class QuickPayPaymentCoordinator { ))) } + private func settleRecovered(invoiceHash: String, bolt11: String, presentation: Presentation) async { + let rows = await listRows() + store.reconcile(rows: rows, liveSubmittingHashes: []) + if store.record(matching: invoiceHash) != nil { + resumePending(invoiceHash: invoiceHash, bolt11: bolt11, presentation: presentation) + return + } + + operations.removeValue(forKey: invoiceHash) + if ldkSucceeded(invoiceHash: invoiceHash, rows: rows) { + presentation.appendRoute(.success(paymentId: invoiceHash)) + return + } + fail( + presentation, + error: AppError(message: t("wallet__payment_failed_description"), debugMessage: "Recovered QuickPay payment is not pending or succeeded"), + bolt11: bolt11 + ) + } + + private func ldkSucceeded(invoiceHash: String, rows: [QuickPayReconcileRow]?) -> Bool { + guard let rows else { return false } + return rows.contains { + $0.isOutboundBolt11 && $0.status == .succeeded && ( + $0.invoicePaymentHash == invoiceHash || $0.paymentId == invoiceHash + ) + } + } + private func resumePending(invoiceHash: String, bolt11: String, presentation: Presentation) { presentation.addPendingPaymentHash(invoiceHash) presentation.appendRoute(.pending(paymentHash: invoiceHash, retryRoute: .quickpay, paymentRequest: bolt11)) diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index d59f44b3e..98a6b666a 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -41,10 +41,14 @@ struct QuickPayLedger: Codable, Equatable { var records: [QuickPayLedgerRecord] } -enum QuickPayTerminalOutcome: Equatable { +enum QuickPayCompletionOutcome: Equatable { case none case settledSuccess case settledFailure + + var wasQuickPay: Bool { + self != .none + } } struct QuickPayReconcileRow { @@ -209,7 +213,7 @@ final class QuickPaySpendStore: @unchecked Sendable { } @discardableResult - func noteTerminal(paymentId: String?, paymentHash: String?, success: Bool) -> QuickPayTerminalOutcome { + func signalCompletion(paymentId: String?, paymentHash: String?, success: Bool) -> QuickPayCompletionOutcome { lock.lock() defer { lock.unlock() } var ledger = lockedLedger() @@ -226,7 +230,7 @@ final class QuickPaySpendStore: @unchecked Sendable { } func releaseBound(paymentHash: String) { - _ = noteTerminal(paymentId: nil, paymentHash: paymentHash, success: false) + _ = signalCompletion(paymentId: nil, paymentHash: paymentHash, success: false) } func reconcile(rows: [QuickPayReconcileRow]?, liveSubmittingHashes: Set) { @@ -247,15 +251,7 @@ final class QuickPaySpendStore: @unchecked Sendable { 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 { + guard let match = pickQuickPayLedgerMatch(record: record, rows: rows) else { remaining.append(record) continue } @@ -361,3 +357,33 @@ final class QuickPaySpendStore: @unchecked Sendable { defaults.set(try? JSONEncoder().encode(ledger), forKey: Self.ledgerDefaultsKey) } } + +private func pickQuickPayLedgerMatch(record: QuickPayLedgerRecord, rows: [QuickPayReconcileRow]) -> QuickPayReconcileRow? { + let matches = rows.filter { row in + row.isOutboundBolt11 && ( + row.invoicePaymentHash == record.invoicePaymentHash + || row.paymentId == record.invoicePaymentHash + || row.paymentId == record.paymentId + || (record.paymentId != nil && row.invoicePaymentHash == record.paymentId) + ) + } + if matches.isEmpty { + return nil + } + if let paymentId = record.paymentId, let exact = matches.first(where: { $0.paymentId == paymentId }) { + return exact + } + return matches.max { lhs, rhs in + lhs.status.rank < rhs.status.rank + } +} + +private extension QuickPayReconcileRow.Status { + var rank: Int { + switch self { + case .succeeded: 2 + case .pending: 1 + case .failed: 0 + } + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 963bca1aa..17e7b1c24 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1062,7 +1062,7 @@ extension AppViewModel { break case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash - let outcome = QuickPaySpendStore.shared.noteTerminal( + let outcome = QuickPaySpendStore.shared.signalCompletion( paymentId: paymentId, paymentHash: paymentHash, success: true @@ -1074,10 +1074,10 @@ extension AppViewModel { sendSheetPendingResolution = SendSheetPendingResolution( paymentHash: hash, success: true, - feePaidSats: outcome != .none ? (feePaidMsat ?? 0) / 1000 : nil + feePaidSats: outcome.wasQuickPay ? (feePaidMsat ?? 0) / 1000 : nil ) } - if awaitingSheet || outcome != .none { + if awaitingSheet || outcome.wasQuickPay { toast( type: .lightning, title: t("wallet__toast_payment_success_title"), @@ -1087,7 +1087,7 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? paymentHash - let outcome = QuickPaySpendStore.shared.noteTerminal( + let outcome = QuickPaySpendStore.shared.signalCompletion( paymentId: paymentId, paymentHash: paymentHash, success: false @@ -1098,7 +1098,7 @@ extension AppViewModel { pendingPaymentHashes.remove(hash) sendSheetPendingResolution = SendSheetPendingResolution(paymentHash: hash, success: false, failureReason: reason) } - if awaitingSheet || outcome != .none { + if awaitingSheet || outcome.wasQuickPay { toast( type: .error, title: t("wallet__toast_payment_failed_title"), diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift index 402342b90..493922a8c 100644 --- a/BitkitTests/QuickPayPaymentCoordinatorTests.swift +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -52,8 +52,8 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { super.tearDown() } - func testDuplicatePaymentIsHardReject() { - XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment(message: "dup"))) + func testDuplicatePaymentIsNotHardReject() { + XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment(message: "dup"))) } func testInvalidInvoiceIsHardReject() { @@ -93,11 +93,11 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { XCTAssertNotNil(store.record(matching: invoiceHash)) } - func testSendAfterTerminalGoesToSuccess() async throws { + func testSendAfterSignalCompletionGoesToSuccess() async throws { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { [store] _ in - store?.noteTerminal(paymentId: nil, paymentHash: invoiceHash, success: true) + store?.signalCompletion(paymentId: nil, paymentHash: invoiceHash, success: true) return invoiceHash } ) @@ -149,7 +149,7 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { XCTAssertNil(store.record(matching: invoiceHash)) } - func testDuplicateDispatchGoesToFailureAndReleases() async throws { + func testDuplicateDispatchWithOpenRecordGoesPending() async throws { let invoiceHash = try Self.invoiceHash let route = await firstRoute( sendBolt11: { _ in @@ -157,11 +157,158 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { } ) - guard case .failure = route else { - return XCTFail("Expected failure, got \(String(describing: route))") + guard case let .pending(paymentHash, retryRoute, _) = route else { + return XCTFail("Expected pending, got \(String(describing: route))") + } + XCTAssertEqual(paymentHash, invoiceHash) + XCTAssertEqual(retryRoute, .quickpay) + XCTAssertNotNil(store.record(matching: invoiceHash)) + XCTAssertEqual(store.spentCentsToday(), 100) + } + + func testDuplicateDispatchWithPendingLdkKeepsSpend() async throws { + let invoiceHash = try Self.invoiceHash + let route = await firstRoute( + sendBolt11: { _ in + throw NodeError.DuplicatePayment(message: "dup") + }, + listRows: { + [ + QuickPayReconcileRow( + paymentId: "pid", + invoicePaymentHash: invoiceHash, + isOutboundBolt11: true, + status: .pending + ), + ] + } + ) + + guard case let .pending(paymentHash, _, _) = route else { + return XCTFail("Expected pending, got \(String(describing: route))") + } + XCTAssertEqual(paymentHash, invoiceHash) + XCTAssertNotNil(store.record(matching: invoiceHash)) + XCTAssertEqual(store.spentCentsToday(), 100) + } + + func testDuplicateDispatchWithSucceededLdkGoesToSuccess() async throws { + let invoiceHash = try Self.invoiceHash + let route = await firstRoute( + sendBolt11: { _ in + throw NodeError.DuplicatePayment(message: "dup") + }, + listRows: { + [ + QuickPayReconcileRow( + paymentId: "pid", + invoicePaymentHash: invoiceHash, + isOutboundBolt11: true, + status: .succeeded + ), + ] + } + ) + + guard case let .success(paymentId) = route else { + return XCTFail("Expected success, got \(String(describing: route))") } + XCTAssertEqual(paymentId, invoiceHash) XCTAssertNil(store.record(matching: invoiceHash)) - XCTAssertEqual(store.spentCentsToday(), 0) + XCTAssertEqual(store.spentCentsToday(), 100) + } + + func testRecoveredHashThatLdkSucceededGoesToSuccess() async throws { + let invoiceHash = try Self.invoiceHash + var sent = false + XCTAssertNotNil( + try store.reserveBound( + paymentHash: invoiceHash, + amountSats: 1000, + thresholdUsd: 5, + multiplier: 5, + rates: rates + ) + ) + + let route = await firstRoute( + sendBolt11: { _ in + sent = true + return invoiceHash + }, + listRows: { + [ + QuickPayReconcileRow( + paymentId: "pid", + invoicePaymentHash: invoiceHash, + isOutboundBolt11: true, + status: .succeeded + ), + ] + } + ) + + XCTAssertFalse(sent) + guard case let .success(paymentId) = route else { + return XCTFail("Expected success, got \(String(describing: route))") + } + XCTAssertEqual(paymentId, invoiceHash) + XCTAssertNil(store.record(matching: invoiceHash)) + XCTAssertEqual(store.spentCentsToday(), 500) + } + + func testSecondPayOfInFlightHashDoesNotFallBackToConfirm() async throws { + let invoiceHash = try Self.invoiceHash + let sendStarted = expectation(description: "send started") + let firstSettled = expectation(description: "first settled") + var sendCount = 0 + var didConfirm = false + var sendCont: CheckedContinuation? + + let coordinator = QuickPayPaymentCoordinator( + store: store, + sendBolt11: { [store] _ in + sendCount += 1 + await withCheckedContinuation { (cont: CheckedContinuation) in + sendCont = cont + sendStarted.fulfill() + } + store?.signalCompletion(paymentId: nil, paymentHash: invoiceHash, success: true) + return invoiceHash + }, + listRows: { [] } + ) + + coordinator.pay( + app: appWithInvoice, + wallet: WalletViewModel(), + settings: settings, + currency: CurrencyViewModel(), + presentation: presentation( + onRoute: { _ in firstSettled.fulfill() }, + onConfirm: { didConfirm = true } + ) + ) + await fulfillment(of: [sendStarted], timeout: 2) + + coordinator.pay( + app: appWithInvoice, + wallet: WalletViewModel(), + settings: settings, + currency: CurrencyViewModel(), + presentation: presentation( + onRoute: { _ in XCTFail("Second pay should not emit a route") }, + onConfirm: { didConfirm = true } + ) + ) + try await Task.sleep(nanoseconds: 150_000_000) + XCTAssertEqual(sendCount, 1) + XCTAssertFalse(didConfirm) + + sendCont?.resume() + await fulfillment(of: [firstSettled], timeout: 2) + XCTAssertEqual(sendCount, 1) + XCTAssertFalse(didConfirm) } private func firstRoute( @@ -176,20 +323,30 @@ final class QuickPayPaymentCoordinatorTests: XCTestCase { wallet: WalletViewModel(), settings: settings, currency: CurrencyViewModel(), - presentation: QuickPayPaymentCoordinator.Presentation( - appendRoute: { + presentation: presentation( + onRoute: { route = $0 exp.fulfill() }, - replaceQuickPay: { _ in }, - addPendingPaymentHash: { _ in }, - routingCacheResetAttempted: false + onConfirm: {} ) ) await fulfillment(of: [exp], timeout: 2) return route } + private func presentation( + onRoute: @escaping (SendRoute) -> Void, + onConfirm: @escaping () -> Void + ) -> QuickPayPaymentCoordinator.Presentation { + QuickPayPaymentCoordinator.Presentation( + appendRoute: onRoute, + replaceQuickPay: { _ in onConfirm() }, + addPendingPaymentHash: { _ in }, + routingCacheResetAttempted: false + ) + } + private var appWithInvoice: AppViewModel { let app = AppViewModel() app.scannedLightningInvoice = LightningInvoice( diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift index e24e0ab62..7e4e68520 100644 --- a/BitkitTests/QuickPaySpendStoreTests.swift +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -73,33 +73,35 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertEqual(sut.spentCentsToday(), 350) } - func testNoteTerminalFailureReleasesSpend() throws { + func testSignalCompletionFailureReleasesSpend() 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.signalCompletion(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) XCTAssertEqual(sut.spentCentsToday(), 0) XCTAssertNil(sut.record(matching: "abc")) } - func testNoteTerminalSuccessKeepsSpend() throws { + func testSignalCompletionSuccessKeepsSpend() 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.signalCompletion(paymentId: nil, paymentHash: "abc", success: true), .settledSuccess) XCTAssertEqual(sut.spentCentsToday(), 500) XCTAssertNil(sut.record(matching: "abc")) + XCTAssertTrue(QuickPayCompletionOutcome.settledSuccess.wasQuickPay) + XCTAssertFalse(QuickPayCompletionOutcome.none.wasQuickPay) } - func testNoteTerminalMatchesPaymentIdAlias() throws { + func testSignalCompletionMatchesPaymentIdAlias() throws { XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) sut.markSubmitted(invoicePaymentHash: "inv", paymentId: "pid") - XCTAssertEqual(sut.noteTerminal(paymentId: "pid", paymentHash: "other", success: false), .settledFailure) + XCTAssertEqual(sut.signalCompletion(paymentId: "pid", paymentHash: "other", success: false), .settledFailure) XCTAssertEqual(sut.spentCentsToday(), 0) } - func testMarkSubmittedAfterTerminalIsNoOp() throws { + func testMarkSubmittedAfterCompletionIsNoOp() 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) + XCTAssertEqual(sut.signalCompletion(paymentId: nil, paymentHash: "inv", success: true), .settledSuccess) sut.markSubmitted(invoicePaymentHash: "inv", paymentId: "pid") @@ -107,10 +109,10 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertEqual(sut.spentCentsToday(), 500) } - func testDuplicateNoteTerminalDoesNotDecrementTwice() throws { + func testDuplicateSignalCompletionDoesNotDecrementTwice() 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.signalCompletion(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) + XCTAssertEqual(sut.signalCompletion(paymentId: nil, paymentHash: "abc", success: false), .none) XCTAssertEqual(sut.spentCentsToday(), 0) } @@ -276,7 +278,7 @@ final class QuickPaySpendStoreTests: XCTestCase { XCTAssertNotNil(try sut.reserveBound(paymentHash: "abc", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) let reloaded = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) - XCTAssertEqual(reloaded.noteTerminal(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) + XCTAssertEqual(reloaded.signalCompletion(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) XCTAssertEqual(reloaded.spentCentsToday(), 0) } From ba388ceccfefe8fd97542972bb8ae7e719fcfe09 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 22 Aug 2026 23:37:46 +0200 Subject: [PATCH 30/30] chore: self review --- .../QuickPayPaymentCoordinator.swift | 14 ++++--- Bitkit/Utilities/QuickPaySpendStore.swift | 38 +++++++++---------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift index e5d495a9a..aef8b577d 100644 --- a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -129,7 +129,7 @@ final class QuickPayPaymentCoordinator { return } - if (operations[invoiceHash] ?? nil) != nil { + if livePresentation(for: invoiceHash) != nil { operations[invoiceHash] = presentation return } @@ -188,7 +188,7 @@ final class QuickPayPaymentCoordinator { return } - let attached = (operations[invoiceHash] ?? nil) ?? presentation + let attached = livePresentation(for: invoiceHash) ?? presentation do { let settled = try await wallet.waitForLightningPayment(hash: invoiceHash) { hash in @@ -220,14 +220,14 @@ final class QuickPayPaymentCoordinator { bolt11: String, presentation: Presentation ) async { - let attached = (operations[invoiceHash] ?? nil) ?? presentation + let attached = livePresentation(for: invoiceHash) ?? presentation if Self.isHardReject(error) { store.releaseBound(paymentHash: invoiceHash) operations.removeValue(forKey: invoiceHash) attached.appendRoute(.failure(SendFailureContext( error: error, retryRoute: .quickpay, - routingCacheResetAttempted: presentation.routingCacheResetAttempted, + routingCacheResetAttempted: attached.routingCacheResetAttempted, paymentRequest: bolt11 ))) return @@ -248,7 +248,7 @@ final class QuickPayPaymentCoordinator { attached.appendRoute(.failure(SendFailureContext( error: error, retryRoute: .quickpay, - routingCacheResetAttempted: presentation.routingCacheResetAttempted, + routingCacheResetAttempted: attached.routingCacheResetAttempted, paymentRequest: bolt11 ))) } @@ -282,6 +282,10 @@ final class QuickPayPaymentCoordinator { } } + private func livePresentation(for invoiceHash: String) -> Presentation? { + operations[invoiceHash] ?? nil + } + private func resumePending(invoiceHash: String, bolt11: String, presentation: Presentation) { presentation.addPendingPaymentHash(invoiceHash) presentation.appendRoute(.pending(paymentHash: invoiceHash, retryRoute: .quickpay, paymentRequest: bolt11)) diff --git a/Bitkit/Utilities/QuickPaySpendStore.swift b/Bitkit/Utilities/QuickPaySpendStore.swift index 98a6b666a..8ffda437e 100644 --- a/Bitkit/Utilities/QuickPaySpendStore.swift +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -251,7 +251,7 @@ final class QuickPaySpendStore: @unchecked Sendable { remaining.append(record) continue } - guard let match = pickQuickPayLedgerMatch(record: record, rows: rows) else { + guard let match = Self.ledgerMatch(record: record, rows: rows) else { remaining.append(record) continue } @@ -356,25 +356,25 @@ final class QuickPaySpendStore: @unchecked Sendable { private func lockedWriteLedger(_ ledger: QuickPayLedger) { defaults.set(try? JSONEncoder().encode(ledger), forKey: Self.ledgerDefaultsKey) } -} -private func pickQuickPayLedgerMatch(record: QuickPayLedgerRecord, rows: [QuickPayReconcileRow]) -> QuickPayReconcileRow? { - let matches = rows.filter { row in - row.isOutboundBolt11 && ( - row.invoicePaymentHash == record.invoicePaymentHash - || row.paymentId == record.invoicePaymentHash - || row.paymentId == record.paymentId - || (record.paymentId != nil && row.invoicePaymentHash == record.paymentId) - ) - } - if matches.isEmpty { - return nil - } - if let paymentId = record.paymentId, let exact = matches.first(where: { $0.paymentId == paymentId }) { - return exact - } - return matches.max { lhs, rhs in - lhs.status.rank < rhs.status.rank + private static func ledgerMatch(record: QuickPayLedgerRecord, rows: [QuickPayReconcileRow]) -> QuickPayReconcileRow? { + let matches = rows.filter { row in + row.isOutboundBolt11 && ( + row.invoicePaymentHash == record.invoicePaymentHash + || row.paymentId == record.invoicePaymentHash + || row.paymentId == record.paymentId + || (record.paymentId != nil && row.invoicePaymentHash == record.paymentId) + ) + } + if matches.isEmpty { + return nil + } + if let paymentId = record.paymentId, let exact = matches.first(where: { $0.paymentId == paymentId }) { + return exact + } + return matches.max { lhs, rhs in + lhs.status.rank < rhs.status.rank + } } }