Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ea2cccd
feat: persist QuickPay daily spend
ovitrif Aug 18, 2026
01d36ca
feat: add QuickPay daily limit setting
ovitrif Aug 18, 2026
fb49623
fix: skip QuickPay over the daily cap
ovitrif Aug 18, 2026
ed1ab25
chore: rename changelog fragment
ovitrif Aug 18, 2026
f0fbf80
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif Aug 18, 2026
dc407b3
fix: port Android QuickPay follow-ups
ovitrif Aug 18, 2026
f158cd5
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif Aug 18, 2026
9d63bf1
fix: QuickPay pending spend and Confirm race
ovitrif Aug 18, 2026
f322977
fix: account QuickPay daily spend in sats
ovitrif Aug 18, 2026
bd45c79
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif Aug 19, 2026
8039526
fix: port QuickPay cents spend ledger
ovitrif Aug 19, 2026
ba1b26f
fix: remount QuickPay on Try Again
ovitrif Aug 19, 2026
afb6294
fix: restore QuickPay spend from metadata backup
ovitrif Aug 20, 2026
73e48f3
fix: count sub-cent quickpay as 1 cent
ovitrif Aug 20, 2026
224a729
fix: restore quickpay cap across backup and pending events
ovitrif Aug 20, 2026
b3bb5d4
fix: restore android quickpay backup payloads
ovitrif Aug 20, 2026
ca69d1f
Merge remote-tracking branch 'origin/master' into fix/670-quickpay-da…
ovitrif Aug 20, 2026
6c79029
fix: drop intro from settings backup mapping
ovitrif Aug 20, 2026
7d79080
fix: map quickpay intro to android settings key
ovitrif Aug 20, 2026
b80be42
fix: keep settings quickpay intro over cache
ovitrif Aug 20, 2026
58fb64b
refactor: shrink quickpay backup restore helpers
ovitrif Aug 20, 2026
4723c38
fix: keep production backup keys plus android aliases
ovitrif Aug 20, 2026
9cffd1b
fix: restore production AppCacheData decoder
ovitrif Aug 20, 2026
c33a00f
chore: drop unused QuickPay leftovers
ovitrif Aug 20, 2026
d56dc63
chore: restore QuickpaySettings preview
ovitrif Aug 20, 2026
a0c208e
chore: drop QuickpaySettings preview
ovitrif Aug 20, 2026
8d390f1
fix: settle QuickPay spend through one ledger
ovitrif Aug 21, 2026
40112d8
refactor: drop unused QuickPay ledger fields
ovitrif Aug 21, 2026
4a4aa9a
refactor: drop intra-PR QuickPay spend migrate
ovitrif Aug 21, 2026
673309c
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif Aug 22, 2026
7f57bc6
fix: map QuickPay threshold to Android backup key
ovitrif Aug 22, 2026
111d828
refactor: drop Paykit from QuickPay hard-reject
ovitrif Aug 22, 2026
5c8032e
fix: resume QuickPay UI when send settles off-sheet
ovitrif Aug 22, 2026
77c4725
fix: keep quickpay spend on dup pay
ovitrif Aug 22, 2026
ba388ce
chore: self review
ovitrif Aug 22, 2026
4d4b94a
fix: keep live records across day prune
ovitrif Aug 23, 2026
3161e0f
fix: replay live quickpay pending session
ovitrif Aug 23, 2026
1e2944c
fix: route open quickpay hash
ovitrif Aug 23, 2026
62a6caf
fix: resolve pending on null hash
ovitrif Aug 23, 2026
6cc2249
fix: snapshot quickpay amount on fallback
ovitrif Aug 23, 2026
5c5a470
fix: keep live quickpay waiter during send
ovitrif Aug 23, 2026
da7dbbc
chore: drop unused quickpay waiter flag
ovitrif Aug 23, 2026
ea624c8
fix: include routing fee on instant QuickPay success
ovitrif Aug 24, 2026
879a408
fix: backup QuickPay spend as a ledger blob
ovitrif Aug 24, 2026
9e1152b
fix: restore QuickPay spend from ledger only
ovitrif Aug 24, 2026
4838c31
fix: update QuickPay daily-limit copy and drop illustration
ovitrif Aug 24, 2026
f3fae45
test: isolate QuickPay zombie rescan from later tests
ovitrif Aug 24, 2026
ddcfd4e
fix: restore QuickPay ledger from Android metadata cache
ovitrif Aug 24, 2026
72438fe
fix: classify QuickPay dispatch errors wrapped in AppError
ovitrif Aug 25, 2026
5c2ba1d
fix: unwind Lightning payment wait on timeout and cancel
ovitrif Aug 25, 2026
5095c96
Merge remote-tracking branch 'origin/master' into fix/670-quickpay-da…
ovitrif Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Bitkit/Components/CustomSlider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,6 +77,7 @@ struct CustomSlider: View {
}
}
)
.accessibilityIdentifierIfPresent(testIdentifier)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Models/SettingsBackupConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ enum SettingsBackupConfig {
"backupVerified": .bool,
"enableNotifications": .bool,
"quickpayAmount": .double(optional: false),
"quickpayDailyLimitMultiplier": .double(optional: false),
]

static var settingsKeys: [String] {
Expand All @@ -65,6 +66,7 @@ enum SettingsBackupConfig {
"warnWhenSendingOver100": "enableSendAmountWarning",
"bitcoinDisplayUnit": "displayUnit",
"enableQuickpay": "isQuickPayEnabled",
"quickpayDailyLimitMultiplier": "quickPayDailyLimitMultiplier",
Comment thread
ovitrif marked this conversation as resolved.
"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
Expand Down
5 changes: 5 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,12 @@
"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";
"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";
Expand Down
63 changes: 45 additions & 18 deletions Bitkit/Utilities/PaymentNavigationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,64 @@ 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 {
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,
multiplier: multiplier,
currency: currency
), let amountUsd = QuickPayLimits.usdValue(sats: amountSats, currency: currency) else {
return false
}

// Check LNURL pay
if let lnurlPayData = app.lnurlPayData {
return lnurlPayData.isFixedAmount && lnurlPayData.minSendableSat <= quickpayAmountSats
let dayKey = QuickPaySpendStore.dayKey()
let spentUsdToday = spendStore.spentUsd(forDayKey: dayKey)
if spentUsdToday + amountUsd <= dailyCapUsd {
return true
}

// Check regular lightning invoice
return app.scannedLightningInvoice!.amountSatoshis <= quickpayAmountSats
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
static func openPaymentSheet(
app: AppViewModel,
currency: CurrencyViewModel,
settings: SettingsViewModel,
sheetViewModel: SheetViewModel
sheetViewModel: SheetViewModel,
spendStore: QuickPaySpendStore = .shared
) {
// Handle LNURL withdraw
if let lnurlWithdrawData = app.lnurlWithdrawData {
Expand All @@ -57,7 +82,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 {
Expand Down Expand Up @@ -100,7 +125,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 {
Expand All @@ -110,7 +136,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 {
Expand Down Expand Up @@ -150,9 +176,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
}

Expand Down
52 changes: 52 additions & 0 deletions Bitkit/Utilities/QuickPayLimits.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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
}

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: 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
}
}
69 changes: 69 additions & 0 deletions Bitkit/Utilities/QuickPaySpendStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 19 additions & 6 deletions Bitkit/ViewModels/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading