Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
42 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
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
76 changes: 58 additions & 18 deletions Bitkit/Utilities/PaymentNavigationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,65 @@ 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 dailyCapSats = QuickPayLimits.dailyCapSats(
thresholdUsd: settings.quickpayAmount,
multiplier: multiplier,
currency: currency
) else {
return false
}

// Check LNURL pay
if let lnurlPayData = app.lnurlPayData {
return lnurlPayData.isFixedAmount && lnurlPayData.minSendableSat <= quickpayAmountSats
let dayKey = QuickPaySpendStore.dayKey()
let spentSatsToday = spendStore.spentSats(forDayKey: dayKey)
let (total, overflow) = spentSatsToday.addingReportingOverflow(amountSats)
if !overflow, total <= dailyCapSats {
return true
}

// Check regular lightning invoice
return app.scannedLightningInvoice!.amountSatoshis <= quickpayAmountSats
Logger.info(
"Skipping QuickPay: daily spend '\(spentSatsToday)' + '\(amountSats)' exceeds cap '\(dailyCapSats)'"
)
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 +83,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 +126,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 +137,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 @@ -147,12 +174,25 @@ struct PaymentNavigationHelper {
return nil
}

static func confirmRouteAfterQuickPayCap(app: AppViewModel) -> SendRoute {
if let lnurlPayData = app.lnurlPayData {
return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount
}

if let invoice = app.scannedLightningInvoice, invoice.amountSatoshis == 0 {
return .amount
}

return .confirm
}

static func contactPaymentRoute(
app: AppViewModel,
currency: CurrencyViewModel,
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
46 changes: 46 additions & 0 deletions Bitkit/Utilities/QuickPayLimits.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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 dailyCapSats(
thresholdUsd: Double,
multiplier: Double,
currency: CurrencyViewModel
) -> UInt64? {
guard let thresholdSats = currency.convert(fiatAmount: thresholdUsd, from: usdCurrencyCode), thresholdSats > 0 else {
return nil
}

let (dailyCapSats, overflow) = thresholdSats.multipliedReportingOverflow(by: UInt64(max(multiplier, 1).rounded()))
return overflow ? UInt64.max : dailyCapSats
}
}
147 changes: 147 additions & 0 deletions Bitkit/Utilities/QuickPaySpendStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Foundation

final class QuickPaySpendStore: @unchecked Sendable {
static let shared = QuickPaySpendStore()

static let dayKeyDefaultsKey = "quickPaySpendDayKey"
static let spentSatsDefaultsKey = "quickPaySpentSatsToday"
static let pendingReservationsDefaultsKey = "quickPayPendingReservations"

private let defaults: UserDefaults
private let lock = NSLock()

struct PendingReservation: Codable, Equatable {
let amountSats: UInt64
let dayKey: String
}

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 spentSats(forDayKey dayKey: String) -> UInt64 {
lock.lock()
defer { lock.unlock() }
return lockedSpend(forDayKey: dayKey).spentSats
}

@discardableResult
func tryReserve(amountSats: UInt64, dayKey: String, dailyCapSats: UInt64) -> Bool {
lock.lock()
defer { lock.unlock() }

let spend = lockedSpend(forDayKey: dayKey)
let (total, overflow) = spend.spentSats.addingReportingOverflow(amountSats)
if overflow || total > dailyCapSats {
return false
}

lockedWrite(dayKey: spend.dayKey, spentSats: total)
return true
}

func release(amountSats: UInt64, dayKey: String) {
lock.lock()
defer { lock.unlock() }

let spend = lockedSpend(forDayKey: dayKey)
let storedDayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? ""
guard spend.dayKey == storedDayKey else { return }
lockedWrite(dayKey: spend.dayKey, spentSats: spend.spentSats > amountSats ? spend.spentSats - amountSats : 0)
}

func record(amountSats: UInt64, dayKey: String) {
lock.lock()
defer { lock.unlock() }

let spend = lockedSpend(forDayKey: dayKey)
let (total, overflow) = spend.spentSats.addingReportingOverflow(amountSats)
lockedWrite(dayKey: spend.dayKey, spentSats: overflow ? UInt64.max : total)
}

func trackPending(paymentHash: String, amountSats: UInt64, dayKey: String) {
guard !paymentHash.isEmpty else { return }

lock.lock()
defer { lock.unlock() }

var pending = lockedPendingReservations()
pending[paymentHash] = PendingReservation(amountSats: amountSats, dayKey: dayKey)
lockedWritePending(pending)
}

func forgetPending(paymentHash: String) {
guard !paymentHash.isEmpty else { return }

lock.lock()
defer { lock.unlock() }

var pending = lockedPendingReservations()
pending.removeValue(forKey: paymentHash)
lockedWritePending(pending)
}

func releasePending(paymentHash: String) {
guard !paymentHash.isEmpty else { return }

lock.lock()
defer { lock.unlock() }

var pending = lockedPendingReservations()
guard let reservation = pending.removeValue(forKey: paymentHash) else { return }
lockedWritePending(pending)

let spend = lockedSpend(forDayKey: reservation.dayKey)
Comment thread
ovitrif marked this conversation as resolved.
Outdated
lockedWrite(
dayKey: spend.dayKey,
spentSats: spend.spentSats > reservation.amountSats ? spend.spentSats - reservation.amountSats : 0
)
}

private func lockedSpend(forDayKey dayKey: String) -> (dayKey: String, spentSats: UInt64) {
let storedDayKey = defaults.string(forKey: Self.dayKeyDefaultsKey) ?? ""
let storedSpend = lockedStoredSpentSats()

if storedDayKey.isEmpty || dayKey > storedDayKey {
return (dayKey, 0)
}
if dayKey == storedDayKey {
return (dayKey, storedSpend)
}
return (storedDayKey, storedSpend)
}

private func lockedStoredSpentSats() -> UInt64 {
UInt64(max(defaults.integer(forKey: Self.spentSatsDefaultsKey), 0))
}

private func lockedWrite(dayKey: String, spentSats: UInt64) {
defaults.set(dayKey, forKey: Self.dayKeyDefaultsKey)
defaults.set(Int(clamping: spentSats), forKey: Self.spentSatsDefaultsKey)
}

private func lockedPendingReservations() -> [String: PendingReservation] {
guard let data = defaults.data(forKey: Self.pendingReservationsDefaultsKey),
let decoded = try? JSONDecoder().decode([String: PendingReservation].self, from: data)
else {
return [:]
}
return decoded
}

private func lockedWritePending(_ pending: [String: PendingReservation]) {
if pending.isEmpty {
defaults.removeObject(forKey: Self.pendingReservationsDefaultsKey)
return
}

defaults.set(try? JSONEncoder().encode(pending), forKey: Self.pendingReservationsDefaultsKey)
}
}
Loading
Loading