Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
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/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -668,6 +670,7 @@ struct AppScene: View {
walletInitShouldFinish = true
app.markAppStatusInit()
BackupService.shared.startObservingBackups()
QuickPayPaymentCoordinator.shared.reconcileAgainstLdk()
Task {
if !PaykitFeatureFlags.isUIEnabled {
await retryPendingPaykitEndpointRemoval()
Expand Down
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
15 changes: 14 additions & 1 deletion Bitkit/Models/BackupPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -112,6 +124,7 @@ struct AppCacheData: Codable {
case hasSeenWidgetsIntro, hasDismissedWidgetsOnboardingHint
case appUpdateIgnoreTimestamp, backupIgnoreTimestamp, highBalanceIgnoreCount, highBalanceIgnoreTimestamp
case dismissedSuggestions, lastUsedTags
case quickPaySpendDayKey, quickPaySpentCentsToday, quickPayReservations
}
}

Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Models/SettingsBackupConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum SettingsBackupConfig {
"highBalanceIgnoreTimestamp",
"dismissedSuggestions",
"lastUsedTags",
"quickPayLedger",
]

static let settingsKeyTypes: [String: SettingKeyType] = [
Expand All @@ -53,6 +54,7 @@ enum SettingsBackupConfig {
"backupVerified": .bool,
"enableNotifications": .bool,
"quickpayAmount": .double(optional: false),
"quickpayDailyLimitMultiplier": .double(optional: false),
]

static var settingsKeys: [String] {
Expand All @@ -65,6 +67,8 @@ enum SettingsBackupConfig {
"warnWhenSendingOver100": "enableSendAmountWarning",
"bitcoinDisplayUnit": "displayUnit",
"enableQuickpay": "isQuickPayEnabled",
"quickpayAmount": "quickPayAmount",
"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
4 changes: 4 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,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";
Expand Down
90 changes: 50 additions & 40 deletions Bitkit/Utilities/PaymentNavigationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,29 @@ 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
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 +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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions Bitkit/Utilities/QuickPayLimits.swift
Original file line number Diff line number Diff line change
@@ -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, &cents, 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
}
}
Loading