-
Notifications
You must be signed in to change notification settings - Fork 4
fix: add quickpay daily spend limit #672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
ovitrif
wants to merge
42
commits into
master
Choose a base branch
from
fix/670-quickpay-day-limit
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
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 01d36ca
feat: add QuickPay daily limit setting
ovitrif fb49623
fix: skip QuickPay over the daily cap
ovitrif ed1ab25
chore: rename changelog fragment
ovitrif f0fbf80
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif dc407b3
fix: port Android QuickPay follow-ups
ovitrif f158cd5
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif 9d63bf1
fix: QuickPay pending spend and Confirm race
ovitrif f322977
fix: account QuickPay daily spend in sats
ovitrif bd45c79
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif 8039526
fix: port QuickPay cents spend ledger
ovitrif ba1b26f
fix: remount QuickPay on Try Again
ovitrif afb6294
fix: restore QuickPay spend from metadata backup
ovitrif 73e48f3
fix: count sub-cent quickpay as 1 cent
ovitrif 224a729
fix: restore quickpay cap across backup and pending events
ovitrif b3bb5d4
fix: restore android quickpay backup payloads
ovitrif ca69d1f
Merge remote-tracking branch 'origin/master' into fix/670-quickpay-da…
ovitrif 6c79029
fix: drop intro from settings backup mapping
ovitrif 7d79080
fix: map quickpay intro to android settings key
ovitrif b80be42
fix: keep settings quickpay intro over cache
ovitrif 58fb64b
refactor: shrink quickpay backup restore helpers
ovitrif 4723c38
fix: keep production backup keys plus android aliases
ovitrif 9cffd1b
fix: restore production AppCacheData decoder
ovitrif c33a00f
chore: drop unused QuickPay leftovers
ovitrif d56dc63
chore: restore QuickpaySettings preview
ovitrif a0c208e
chore: drop QuickpaySettings preview
ovitrif 8d390f1
fix: settle QuickPay spend through one ledger
ovitrif 40112d8
refactor: drop unused QuickPay ledger fields
ovitrif 4a4aa9a
refactor: drop intra-PR QuickPay spend migrate
ovitrif 673309c
Merge branch 'master' into fix/670-quickpay-day-limit
ovitrif 7f57bc6
fix: map QuickPay threshold to Android backup key
ovitrif 111d828
refactor: drop Paykit from QuickPay hard-reject
ovitrif 5c8032e
fix: resume QuickPay UI when send settles off-sheet
ovitrif 77c4725
fix: keep quickpay spend on dup pay
ovitrif ba388ce
chore: self review
ovitrif 4d4b94a
fix: keep live records across day prune
ovitrif 3161e0f
fix: replay live quickpay pending session
ovitrif 1e2944c
fix: route open quickpay hash
ovitrif 62a6caf
fix: resolve pending on null hash
ovitrif 6cc2249
fix: snapshot quickpay amount on fallback
ovitrif 5c5a470
fix: keep live quickpay waiter during send
ovitrif da7dbbc
chore: drop unused quickpay waiter flag
ovitrif File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
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) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.