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/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/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index e3f38fef1..2d885972f 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? = nil, + quickPaySpentCentsToday: Int64? = nil, + quickPayReservations: [String: QuickPaySpendReservation]? = nil ) { 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) + 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 73c0738e1..591c04979 100644 --- a/Bitkit/Models/SettingsBackupConfig.swift +++ b/Bitkit/Models/SettingsBackupConfig.swift @@ -32,6 +32,7 @@ enum SettingsBackupConfig { "highBalanceIgnoreTimestamp", "dismissedSuggestions", "lastUsedTags", + "quickPayLedger", ] static let settingsKeyTypes: [String: SettingKeyType] = [ @@ -53,6 +54,7 @@ enum SettingsBackupConfig { "backupVerified": .bool, "enableNotifications": .bool, "quickpayAmount": .double(optional: false), + "quickpayDailyLimitMultiplier": .double(optional: false), ] static var settingsKeys: [String] { @@ -65,6 +67,8 @@ enum SettingsBackupConfig { "warnWhenSendingOver100": "enableSendAmountWarning", "bitcoinDisplayUnit": "displayUnit", "enableQuickpay": "isQuickPayEnabled", + "quickpayAmount": "quickPayAmount", + "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 b31175674..3a16a7d94 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -801,7 +801,11 @@ "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__multiplier_format" = "{multiplier}×"; "settings__quickpay__settings__note" = "* Bitkit QuickPay exclusively supports payments from your Spending Balance."; +"wallet__send_quickpay__currency_conversion" = "Currency conversion failed"; "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/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index a9c30fd11..eda7d720f 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -12,31 +12,20 @@ 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 { + guard let amountSats = QuickPayLimits.paymentAmountSats(app: app), amountSats > 0 else { return false } - // We need a lightning invoice or LNURL pay data to use quickpay - guard app.scannedLightningInvoice != nil || app.lnurlPayData != nil else { - return false - } - - // Convert quickpay amount from USD to sats - let quickpayAmountSats = currency.convert(fiatAmount: settings.quickpayAmount, from: "USD") ?? 0 - guard quickpayAmountSats > 0 else { - return false - } - - // Check LNURL pay - if let lnurlPayData = app.lnurlPayData { - return lnurlPayData.isFixedAmount && lnurlPayData.minSendableSat <= quickpayAmountSats - } - - // Check regular lightning invoice - return app.scannedLightningInvoice!.amountSatoshis <= quickpayAmountSats + return spendStore.canApply( + amountSats: amountSats, + enabled: settings.enableQuickpay, + thresholdUsd: settings.quickpayAmount, + multiplier: settings.quickpayDailyLimitMultiplier, + rates: .live(currency) + ) } /// Centralized method to open the appropriate sheet based on the current state @@ -44,7 +33,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 +47,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 +90,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 +101,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 { @@ -147,30 +138,49 @@ 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 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, - 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 } 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/Utilities/QuickPayLimits.swift b/Bitkit/Utilities/QuickPayLimits.swift new file mode 100644 index 000000000..441106057 --- /dev/null +++ b/Bitkit/Utilities/QuickPayLimits.swift @@ -0,0 +1,54 @@ +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 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 + } + + static func dailyCapUsdDisplay(thresholdUsd: Double, multiplier: Double) -> Int { + 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, 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 { + var cents = converted.value * 100 + var rounded = Decimal() + NSDecimalRound(&rounded, ¢s, 0, .plain) + return NSDecimalNumber(decimal: rounded).int64Value + } + + @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 + } +} diff --git a/Bitkit/Utilities/QuickPayPaymentCoordinator.swift b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift new file mode 100644 index 000000000..8ba39ef76 --- /dev/null +++ b/Bitkit/Utilities/QuickPayPaymentCoordinator.swift @@ -0,0 +1,239 @@ +import Foundation +import LDKNode + +@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 let store: QuickPaySpendStore + private let sendBolt11: (String) async throws -> String + private let listRows: () async -> [QuickPayReconcileRow]? + + private var operations: [String: Presentation?] = [:] + 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 Array(operations.keys) { + operations[hash] = Optional.none + } + } + + 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 isHardReject(_ error: Error) -> Bool { + guard let nodeError = error as? NodeError else { + return false + } + switch nodeError { + case .InvalidInvoice, .InvalidAmount, .InvalidPaymentHash, .InvalidPaymentId, .InvalidNetwork, .DuplicatePayment: + return true + default: + return false + } + } + + 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 operations[invoiceHash] != nil { + operations[invoiceHash] = presentation + return + } + + if store.record(matching: invoiceHash) != nil { + operations[invoiceHash] = presentation + return + } + + guard generation == self.generation else { return } + + let amountSats = wallet.sendAmountSats ?? 0 + do { + 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 generation == self.generation else { + store.releaseBound(paymentHash: invoiceHash) + return + } + + operations[invoiceHash] = presentation + + do { + let paymentId = try await sendBolt11(bolt11) + store.markSubmitted(invoicePaymentHash: invoiceHash, paymentId: paymentId) + 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] ?? 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]??.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]??.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] ?? nil + 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 + ))) + } + + 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 new file mode 100644 index 000000000..d59f44b3e --- /dev/null +++ b/Bitkit/Utilities/QuickPaySpendStore.swift @@ -0,0 +1,363 @@ +import Foundation +import LDKNode + +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) + } + ) + } +} + +struct QuickPayLedgerRecord: Codable, Equatable { + let amountCents: Int64 + let dayKey: String + let invoicePaymentHash: String + var paymentId: String? +} + +struct QuickPayLedger: Codable, Equatable { + 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() + + static let ledgerDefaultsKey = "quickPayLedger" + + private let defaults: UserDefaults + private let lock = NSLock() + private let dayKeyProvider: () -> String + + init(defaults: UserDefaults = .standard, dayKey: @escaping () -> String = { QuickPaySpendStore.dayKey() }) { + self.defaults = defaults + dayKeyProvider = dayKey + } + + 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 spentCentsToday() -> Int64 { + lock.lock() + defer { lock.unlock() } + return lockedSpend(forDayKey: dayKeyProvider()).spentCents + } + + 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 } + + let reserveCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd, amountSats: amountSats) + let capCents = QuickPayLimits.capCents(thresholdUsd: thresholdUsd, multiplier: multiplier) + + lock.lock() + defer { lock.unlock() } + let spentCents = lockedSpend(forDayKey: dayKeyProvider()).spentCents + let (total, overflow) = spentCents.addingReportingOverflow(reserveCents) + if !overflow, total <= capCents { + return true + } + + Logger.info( + "Skipping QuickPay: daily spend '\(spentCents)' + '\(reserveCents)' exceeds cap '\(capCents)'" + ) + return false + } + + 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 -> QuickPayLedgerRecord? { + guard !paymentHash.isEmpty else { return nil } + guard let thresholdSats = rates.usdToSats(thresholdUsd), thresholdSats > 0, amountSats <= thresholdSats else { + return nil + } + guard let convertedCents = rates.satsToUsdCents(amountSats) else { + throw QuickPayConversionError() + } + + let amountCents = QuickPayLimits.reserveCents(convertedCents: convertedCents, thresholdUsd: thresholdUsd, amountSats: amountSats) + let capCents = QuickPayLimits.capCents(thresholdUsd: thresholdUsd, multiplier: multiplier) + + 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 + } + + var ledger = lockedLedger() + lockedPrune(ledger: &ledger, currentDay: spend.dayKey) + let record = QuickPayLedgerRecord( + amountCents: amountCents, + dayKey: spend.dayKey, + invoicePaymentHash: paymentHash, + paymentId: nil + ) + ledger.dayKey = spend.dayKey + ledger.spentCents = total + ledger.records.append(record) + lockedWriteLedger(ledger) + return record + } + + func markSubmitted(invoicePaymentHash: String, paymentId: String?) { + guard !invoicePaymentHash.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + var ledger = lockedLedger() + guard let index = lockedRecordIndex(in: ledger, matching: invoicePaymentHash) else { return } + ledger.records[index].paymentId = paymentId + lockedWriteLedger(ledger) + } + + @discardableResult + func noteTerminal(paymentId: String?, paymentHash: String?, success: Bool) -> QuickPayTerminalOutcome { + lock.lock() + defer { lock.unlock() } + 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 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 + } + } + + if didWrite || remaining.count != ledger.records.count { + ledger.records = remaining + lockedWriteLedger(ledger) + } + } + + func backupSnapshot() -> ( + dayKey: String, + spentCents: Int64, + reservations: [String: QuickPaySpendReservation] + ) { + lock.lock() + defer { lock.unlock() } + let ledger = lockedLedger() + var reservations: [String: QuickPaySpendReservation] = [:] + for record in ledger.records { + reservations[record.invoicePaymentHash] = QuickPaySpendReservation( + amountCents: record.amountCents, + dayKey: record.dayKey + ) + } + return (ledger.dayKey, ledger.spentCents, reservations) + } + + func restoreFromBackup( + dayKey: String, + spentCents: Int64, + reservations: [String: QuickPaySpendReservation] + ) { + lock.lock() + defer { lock.unlock() } + var records: [QuickPayLedgerRecord] = [] + for (hash, reservation) in reservations { + records.append( + QuickPayLedgerRecord( + amountCents: reservation.amountCents, + dayKey: reservation.dayKey, + invoicePaymentHash: hash, + paymentId: nil + ) + ) + } + lockedWriteLedger(QuickPayLedger(dayKey: dayKey, spentCents: max(spentCents, 0), records: records)) + } + + private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentCents: Int64) { + 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 == ledger.dayKey { + return (dayKey, ledger.spentCents) + } + return (ledger.dayKey, ledger.spentCents) + } + + private func lockedPrune(ledger: inout QuickPayLedger, currentDay: String) { + guard !currentDay.isEmpty else { return } + ledger.records.removeAll { $0.dayKey < currentDay } + } + + private func lockedRecord(matching hash: String) -> QuickPayLedgerRecord? { + guard let index = lockedRecordIndex(in: lockedLedger(), matching: hash) else { return nil } + return lockedLedger().records[index] + } + + private func lockedRecordIndex(in ledger: QuickPayLedger, matching hash: String) -> Int? { + ledger.records.firstIndex { + $0.invoicePaymentHash == hash || $0.paymentId == hash + } + } + + private func lockedLedger() -> QuickPayLedger { + guard let data = defaults.data(forKey: Self.ledgerDefaultsKey), + let decoded = try? JSONDecoder().decode(QuickPayLedger.self, from: data) + else { + return QuickPayLedger(dayKey: "", spentCents: 0, records: []) + } + return decoded + } + + private func lockedWriteLedger(_ ledger: QuickPayLedger) { + defaults.set(try? JSONEncoder().encode(ledger), forKey: Self.ledgerDefaultsKey) + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 41c385f5e..963bca1aa 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 } } @@ -92,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. @@ -389,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 @@ -801,6 +821,7 @@ extension AppViewModel { if !preservingContactPaymentContext { contactPaymentContext = nil } + resetQuickPay() } } @@ -1039,11 +1060,24 @@ extension AppViewModel { } case .channelClosed(channelId: _, userChannelId: _, counterpartyNodeId: _, reason: _): break - case let .paymentSuccessful(paymentId, paymentHash, _, _): + case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): let hash = paymentId ?? paymentHash - if pendingPaymentHashes.contains(hash) { + 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) + sendSheetPendingResolution = SendSheetPendingResolution( + paymentHash: hash, + success: true, + feePaidSats: outcome != .none ? (feePaidMsat ?? 0) / 1000 : nil + ) + } + if awaitingSheet || outcome != .none { toast( type: .lightning, title: t("wallet__toast_payment_success_title"), @@ -1053,9 +1087,18 @@ extension AppViewModel { } case let .paymentFailed(paymentId, paymentHash, reason): let hash = paymentId ?? 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 4c14cf7e7..d1cd2bc19 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") @@ -854,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"), @@ -870,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 ) } @@ -892,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 ?? 0, + reservations: cache.quickPayReservations ?? [:] + ) } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 60ddecdab..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) @@ -780,6 +781,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. @@ -869,13 +875,31 @@ class WalletViewModel: ObservableObject { bolt11: String, sats: UInt64? = nil, timeoutSeconds: TimeInterval = 10, - onTimeout: (@MainActor () -> Void)? = nil - ) async throws -> PaymentHash { - try await withThrowingTaskGroup(of: PaymentHash.self) { group in - group.addTask { try await self.send(bolt11: bolt11, sats: sats) } + afterListening: (@MainActor (String) -> Void)? = nil, + onTimeout: (@MainActor (String) -> Void)? = nil + ) async throws -> SettledLightningPayment { + let hash = try await lightningService.send(bolt11: bolt11, sats: sats) + let paymentHash = String(hash) + 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() } } + if let onTimeout { + await MainActor.run { onTimeout(hash) } + } throw PaymentTimeoutError.timedOut } let first = try await group.next()! @@ -888,18 +912,24 @@ 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) + return try await watchSend(hash: String(hash)) + } + + private func watchSend(hash: String) 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, _, _): + 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 ca920ec57..3018c7aef 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,39 @@ 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: { + t( + "settings__quickpay__settings__multiplier_format", + variables: ["multiplier": String(Int($0))] + ) + }, + testIdentifier: "QuickpayDailyLimitSlider" + ) } .padding(.top, 32) @@ -42,7 +79,6 @@ struct QuickpaySettings: View { } .frame(maxWidth: .infinity) .padding(.horizontal, 16) - // .padding(.vertical, 32) BodySText(t("settings__quickpay__settings__note")) } 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 d93392b7d..cc6d516dd 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -78,25 +78,36 @@ struct SendPendingScreen: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { + applyPendingResolutionIfNeeded(app.sendSheetPendingResolution) await searchForActivity() } .onChange(of: app.sendSheetPendingResolution) { _, resolution in - guard let resolution, resolution.paymentHash == paymentHash else { return } - app.consumeSendSheetPendingResolution(paymentHash: paymentHash) - if resolution.success { - Task { @MainActor in - 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/Bitkit/Views/Wallets/Send/SendQuickpay.swift b/Bitkit/Views/Wallets/Send/SendQuickpay.swift index 63e7efb50..7147fb823 100644 --- a/Bitkit/Views/Wallets/Send/SendQuickpay.swift +++ b/Bitkit/Views/Wallets/Send/SendQuickpay.swift @@ -1,13 +1,15 @@ -import LDKNode import SwiftUI struct SendQuickpay: View { @EnvironmentObject var app: AppViewModel - @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @Binding var navigationPath: [SendRoute] let routingCacheResetAttempted: Bool + var replaceQuickPay: (SendRoute) -> Void + @State private var didStartPayment = false var body: some View { VStack { @@ -34,67 +36,23 @@ struct SendQuickpay: View { .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { - Task { - await performPayment() - } - } - } - - private func performPayment() async { - 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( - data: lnurlPayData, - amountMsats: lnurlPayData.callbackAmountMsats() + guard !didStartPayment else { return } + didStartPayment = true + 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 ) - } else if let scannedInvoice = app.scannedLightningInvoice { - wallet.sendAmountSats = scannedInvoice.amountSatoshis - bolt11Invoice = scannedInvoice.bolt11 - } - - guard let bolt11 = bolt11Invoice else { - throw NSError( - domain: "Payment", code: -1, userInfo: [NSLocalizedDescriptionKey: "No Lightning invoice found"] - ) - } - - 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)) - } catch is PaymentTimeoutError { - // onTimeout callback already navigated to .pending; suppress throw - return - } catch { - handlePaymentError(error, paymentRequest: bolt11Invoice) } - } - - 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 - ))) + .onDisappear { + QuickPayPaymentCoordinator.shared.detach() + } } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 696c02dde..574c0ee9c 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -87,11 +87,17 @@ struct SendSheet: View { let config: SendSheetItem @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 @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 +151,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 +193,8 @@ 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. @@ -466,7 +474,12 @@ struct SendSheet: View { case .tag: SendTagScreen(navigationPath: $navigationPath) case .quickpay: - SendQuickpay(navigationPath: $navigationPath, routingCacheResetAttempted: routingCacheResetAttempted) + SendQuickpay( + navigationPath: $navigationPath, + routingCacheResetAttempted: routingCacheResetAttempted, + replaceQuickPay: replaceQuickPay(with:) + ) + .id(quickPaySession) case .pin: SendPinScreen(onCancel: { resolvePinCheck(false) }, onPinVerified: { resolvePinCheck(true) }) case let .pending(paymentHash, retryRoute, paymentRequest): @@ -525,9 +538,28 @@ 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() + quickPaySession += 1 + } + if route == config.initialRoute || currentRoot == route { + if route == config.initialRoute { + rootOverride = nil + } + navigationPath = [] + return + } + + navigationPath = [route] } } diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift index a68c34b78..54b2b215c 100644 --- a/BitkitTests/AddressTypeSettingsTests.swift +++ b/BitkitTests/AddressTypeSettingsTests.swift @@ -253,6 +253,8 @@ final class AddressTypeSettingsTests: XCTestCase { settings.addressTypesToMonitor = [.nativeSegwit, .taproot, .legacy] settings.hideBalance = true settings.enableQuickpay = true + settings.quickpayAmount = 1 + settings.quickpayDailyLimitMultiplier = 10 UserDefaults.standard.synchronize() let backupDict = settings.getSettingsDictionary() @@ -274,5 +276,29 @@ 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]) + + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 3) + } + + func testInvalidDailyLimitMultiplierFallsBackToDefault() { + settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 7]) + + XCTAssertEqual(settings.quickpayDailyLimitMultiplier, 5) } } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 16dd13d44..8c18c494a 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -7,20 +7,30 @@ 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 @@ -31,6 +41,7 @@ final class PaymentNavigationHelperTests: XCTestCase { override func tearDown() { settings.enableQuickpay = originalEnableQuickpay settings.quickpayAmount = originalQuickpayAmount + settings.quickpayDailyLimitMultiplier = originalQuickpayDailyLimitMultiplier settings.pinEnabled = originalPinEnabled settings.requirePinForPayments = originalRequirePinForPayments @@ -39,6 +50,10 @@ final class PaymentNavigationHelperTests: XCTestCase { } else { UserDefaults.standard.removeObject(forKey: "cached_fx_rates") } + + spendDefaults.removePersistentDomain(forName: spendSuiteName) + spendDefaults = nil + spendStore = nil super.tearDown() } @@ -46,13 +61,71 @@ final class PaymentNavigationHelperTests: XCTestCase { settings.pinEnabled = true settings.requirePinForPayments = true + XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) + } + + func testEligibleInvoiceUsesQuickpayUnderDailyCap() { + XCTAssertEqual(sendRoute(for: appWithEligibleInvoice), .quickpay) + } + + func testSkipsQuickpayWhenDailySpendCapIsExceeded() throws { + let rates = QuickPaySpendRates.live(CurrencyViewModel()) + 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) + } + + func testAllowsQuickpayWhenSpendPlusAmountEqualsDailyCap() throws { + let rates = QuickPaySpendRates.live(CurrencyViewModel()) + for i in 0 ..< 4 { + XCTAssertNotNil(try spendStore.reserveBound(paymentHash: "under\(i)", amountSats: 5000, 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) + } + + func testReserveRaceFallsBackToConfirm() { XCTAssertEqual( - PaymentNavigationHelper.appropriateSendRoute( - app: appWithEligibleInvoice, - currency: CurrencyViewModel(), - settings: settings - ), - .quickpay + PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: appWithEligibleInvoice), + .confirm + ) + } + + 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, + currency: CurrencyViewModel(), + settings: settings, + spendStore: spendStore ) } diff --git a/BitkitTests/QuickPayLimitsTests.swift b/BitkitTests/QuickPayLimitsTests.swift new file mode 100644 index 000000000..50e52f9c5 --- /dev/null +++ b/BitkitTests/QuickPayLimitsTests.swift @@ -0,0 +1,26 @@ +@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 testCapCentsUsesIntegerUsdAndMultiplier() { + XCTAssertEqual(QuickPayLimits.capCents(thresholdUsd: 5, multiplier: 5), 2500) + 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() { + XCTAssertEqual(QuickPayLimits.amountWithFeeSats(amountSats: 1000, feePaidSats: 12), 1012) + XCTAssertEqual(QuickPayLimits.amountWithFeeSats(amountSats: UInt64.max, feePaidSats: 1), UInt64.max) + } +} diff --git a/BitkitTests/QuickPayPaymentCoordinatorTests.swift b/BitkitTests/QuickPayPaymentCoordinatorTests.swift new file mode 100644 index 000000000..f164430d9 --- /dev/null +++ b/BitkitTests/QuickPayPaymentCoordinatorTests.swift @@ -0,0 +1,18 @@ +@testable import Bitkit +import LDKNode +import XCTest + +@MainActor +final class QuickPayPaymentCoordinatorTests: XCTestCase { + func testDuplicatePaymentIsHardReject() { + XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.DuplicatePayment(message: "dup"))) + } + + func testInvalidInvoiceIsHardReject() { + XCTAssertTrue(QuickPayPaymentCoordinator.isHardReject(NodeError.InvalidInvoice(message: "bad"))) + } + + func testPersistenceIsNotHardReject() { + XCTAssertFalse(QuickPayPaymentCoordinator.isHardReject(NodeError.PersistenceFailed(message: "io"))) + } +} diff --git a/BitkitTests/QuickPaySpendStoreTests.swift b/BitkitTests/QuickPaySpendStoreTests.swift new file mode 100644 index 000000000..e24e0ab62 --- /dev/null +++ b/BitkitTests/QuickPaySpendStoreTests.swift @@ -0,0 +1,283 @@ +@testable import Bitkit +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) } + ) + private let dustRates = QuickPaySpendRates( + satsToUsdCents: { _ in 0 }, + usdToSats: { usd in UInt64(usd * 200) } + ) + + override func setUp() { + super.setUp() + suiteName = "QuickPaySpendStoreTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + currentDay = "2026-08-15" + sut = QuickPaySpendStore(defaults: defaults, dayKey: { [unowned self] in currentDay }) + } + + 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 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) + } + + 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.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.reserveBound(paymentHash: "a", amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: rates)) + currentDay = "2026-08-14" + + XCTAssertEqual(sut.spentCentsToday(), 250) + 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 testNoteTerminalFailureReleasesSpend() 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.spentCentsToday(), 0) + XCTAssertNil(sut.record(matching: "abc")) + } + + 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 testNoteTerminalMatchesPaymentIdAlias() 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.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 testReleaseOnAPriorDayDoesNotDecrementTheNewDay() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "old", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + currentDay = "2026-08-16" + XCTAssertNotNil(try sut.reserveBound(paymentHash: "new", amountSats: 800, thresholdUsd: 5, multiplier: 5, rates: rates)) + + sut.releaseBound(paymentHash: "old") + + XCTAssertEqual(sut.spentCentsToday(), 400) + XCTAssertNil(sut.record(matching: "old")) + } + + func testReconcileFailedReleasesRecoveredRecord() 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: [] + ) + + XCTAssertEqual(sut.spentCentsToday(), 0) + XCTAssertNil(sut.record(matching: "inv")) + } + + func testReconcileSucceededClearsAndKeepsSpend() 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: .succeeded)], + liveSubmittingHashes: [] + ) + + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNil(sut.record(matching: "inv")) + } + + func testReconcileAbsentOrPendingRetains() throws { + XCTAssertNotNil(try sut.reserveBound(paymentHash: "inv", amountSats: 1000, thresholdUsd: 5, multiplier: 5, rates: rates)) + + sut.reconcile(rows: [], liveSubmittingHashes: []) + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNotNil(sut.record(matching: "inv")) + + 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 testAppCacheDataDecodesLegacySpendFields() throws { + let json = """ + { + "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": { + "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 ?? 0, + reservations: cache.quickPayReservations ?? [:] + ) + + XCTAssertEqual(sut.spentCentsToday(), 500) + XCTAssertNotNil(sut.record(matching: "abc")) + } + + func testCanApplyIsTrueUnderThresholdAndCap() { + XCTAssertTrue( + sut.canApply(amountSats: 500, enabled: true, thresholdUsd: 5, multiplier: 5, rates: rates) + ) + } + + func testZeroCentConversionAtFullCapDoesNotQuickPay() throws { + 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.reserveBound(paymentHash: "dust", amountSats: 7, thresholdUsd: 5, multiplier: 5, rates: dustRates)) + } + + func testZeroCentConversionReservesOneCent() throws { + 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.reserveBound(paymentHash: "a", amountSats: 1000, thresholdUsd: 5, multiplier: 1, rates: rates)) + + XCTAssertFalse( + sut.canApply(amountSats: 1000, enabled: true, thresholdUsd: 5, multiplier: 1, rates: rates) + ) + } + + func testCanApplyIsFalseWhenDisabled() { + XCTAssertFalse( + sut.canApply(amountSats: 500, enabled: false, thresholdUsd: 5, multiplier: 5, rates: rates) + ) + } + + func testReserveBoundFailsWithConversionErrorWhenRatesAreUnavailable() { + let missingRates = QuickPaySpendRates( + satsToUsdCents: { _ in nil }, + usdToSats: { usd in UInt64(usd * 200) } + ) + + XCTAssertThrowsError( + try sut.reserveBound(paymentHash: "abc", amountSats: 500, thresholdUsd: 5, multiplier: 5, rates: missingRates) + ) { error in + XCTAssertTrue(error is QuickPayConversionError) + } + } + + 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 }) + XCTAssertEqual(reloaded.noteTerminal(paymentId: nil, paymentHash: "abc", success: false), .settledFailure) + + XCTAssertEqual(reloaded.spentCentsToday(), 0) + } +} diff --git a/changelog.d/next/672.security.md b/changelog.d/next/672.security.md new file mode 100644 index 000000000..3a5339f9f --- /dev/null +++ b/changelog.d/next/672.security.md @@ -0,0 +1 @@ +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead.