Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b6dd98d
fix: release a failed automatic payment's list once
ovitrif Oct 27, 2026
411b050
fix: keep auto-paid labels and totals past 200 allowance attempts
ovitrif Oct 27, 2026
d3ce74b
fix: keep a bitcoin request an allowance can't pay over lightning manual
ovitrif Oct 27, 2026
6f7354a
fix: load allowance contacts and identity once the paykit session app…
ovitrif Oct 27, 2026
114dcaa
fix: announce every allowance the wallet accepts by itself
ovitrif Oct 27, 2026
9d096f6
fix: say an allowance PIN check confirms the allowance change
ovitrif Oct 27, 2026
cc24f0e
chore: add changelog fragment for allowance follow-ups
ovitrif Oct 27, 2026
26074ba
fix: release a failed payment's list only after its outcome is saved
ovitrif Oct 27, 2026
2ac0e56
fix: count auto-paid history for the signed-in identity only
ovitrif Oct 27, 2026
e641ef2
fix: start allowances again when a session change cuts the start short
ovitrif Oct 27, 2026
29ecfc2
fix: name each contact once in a combined allowance added notice
ovitrif Oct 27, 2026
99b02d1
fix: keep allowance labels and open attempts across a wallet restore
ovitrif Oct 27, 2026
48f34cf
docs: note how allowance journey requests are priced on each platform
ovitrif Oct 27, 2026
d0b9084
fix: keep a bad allowance backup entry from failing the wallet backup
ovitrif Oct 27, 2026
e4e6b85
chore: cover the restore fix in the allowance changelog entry
ovitrif Oct 27, 2026
aa2b648
fix: restart allowances for a session that appeared mid-start and mer…
ovitrif Oct 27, 2026
e56f58a
fix: write absent allowance backup values as null so android restores…
ovitrif Oct 27, 2026
27d1675
docs: add the restore keeps history allowance journey
ovitrif Oct 27, 2026
100d7b2
fix: let the node settle a restored allowance attempt
ovitrif Oct 27, 2026
37b2ed7
fix: announce an accepted allowance offer once when its mark fails to…
ovitrif Oct 27, 2026
7296854
fix: retry clearing an announced allowance offer's mark
ovitrif Oct 27, 2026
fb62396
fix: leave a restored allowance attempt open until the node can settl…
ovitrif Oct 27, 2026
08225c9
fix: forget announced allowance offers on sign-out or profile switch
ovitrif Oct 27, 2026
e71b591
fix: give back a consumed payment list after a restart
ovitrif Oct 27, 2026
3335054
fix: never consume a payment list for an attempt the journal lost
ovitrif Oct 27, 2026
73a4fcf
fix: keep a payment list's owner through the wallet backup
ovitrif Oct 27, 2026
f085773
docs: match the ended allowance row in the end journey
ovitrif Oct 27, 2026
f3b5825
fix: leave a payment in progress out of recovery
ovitrif Oct 28, 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
89 changes: 61 additions & 28 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ struct AppScene: View {
activityChanges: CoreService.shared.activity.activitiesChangedPublisher
)
@State private var paykitAllowanceManager = PaykitAllowanceManager()
@State private var activatedAutomaticPaykitIdentity: String?

@State private var hideSplash = false
@State private var removeSplash = false
Expand Down Expand Up @@ -611,33 +612,23 @@ struct AppScene: View {
receivedPaymentBackfillCache.invalidate()
if authState == .authenticated, let pk = pubkyProfile.publicKey {
paykitPaymentRequestManager.activate(identity: pk)
Task {
guard let session = pubkyProfile.automaticPaykitSession,
PubkyPublicKeyFormat.matches(pk, session.publicKey)
else { return }
await paykitAllowanceManager.activate(identity: pk)
await handlePendingPaykitSubscriptionNotification()
guard pubkyProfile.automaticPaykitSession == session else { return }
try? await contactsManager.loadContacts(for: pk)
if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true {
await retryPendingPaykitEndpointRemoval()
}
guard isPaykitSceneActive, pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey)
else { return }
await refreshPrivateOnlyPaykitApp()
await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true)
if PaykitSubscriptionNotificationTargetStore.load() == nil {
await presentNextIncomingPaykitItem()
}
}
startAutomaticPaykitSession()
} else if authState == .idle {
activatedAutomaticPaykitIdentity = nil
contactsManager.reset()
paykitPaymentRequestManager.clear()
paykitAllowanceManager.deactivate()
hasPendingExpiryToast = false
}
}
// A sign-in, or a session that was still changing or being deleted, has no automatic session yet when
// authState turns authenticated; activate allowances and load contacts once it appears.
.onReceive(PubkyProfileManager.sessionSettledPublisher) { startAutomaticPaykitSession() }
.onChange(of: pubkyProfile.isDeletingProfile) { _, isDeleting in
if !isDeleting {
startAutomaticPaykitSession()
}
}
.onReceive(contactsManager.savedContactsChangedPublisher) { contacts in
let publicKeys = contacts.map(\.publicKey)
guard PaykitFeatureFlags.isUIEnabled,
Expand Down Expand Up @@ -1261,7 +1252,9 @@ struct AppScene: View {
if !PaykitFeatureFlags.isUIEnabled {
await retryPendingPaykitEndpointRemoval()
}
guard PaykitFeatureFlags.isUIEnabled, isPaykitSceneActive else { return }
guard PaykitFeatureFlags.isUIEnabled else { return }
await paykitAllowanceManager.recoverOpenAttempts()
guard isPaykitSceneActive else { return }
await refreshPrivateOnlyPaykitApp()
await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk()
await prepareSavedPaykitContacts()
Expand Down Expand Up @@ -2016,16 +2009,56 @@ struct AppScene: View {
}
}

private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) {
for offer in offers {
let title = t("subscriptions__allowance_offer_accepted_title")
let description = t("subscriptions__allowance_offer_accepted_description", variables: ["name": contactName(offer.counterparty)])
PaykitAllowanceNotifier.post(title: title, body: description, fallback: {
app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast")
})
private func startAutomaticPaykitSession() {
guard let session = pubkyProfile.automaticPaykitSession,
!PubkyPublicKeyFormat.matches(session.publicKey, activatedAutomaticPaykitIdentity)
else { return }
let pk = session.publicKey
activatedAutomaticPaykitIdentity = pk
Task {
await paykitAllowanceManager.activate(identity: pk)
await handlePendingPaykitSubscriptionNotification()
guard pubkyProfile.automaticPaykitSession == session else {
// A deletion or session change cut this start short; let the session's next appearance run it again.
if activatedAutomaticPaykitIdentity == pk {
activatedAutomaticPaykitIdentity = nil
}
// A new session for the same key may already have appeared while this one waited.
startAutomaticPaykitSession()
return
}
try? await contactsManager.loadContacts(for: pk)
if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true {
await retryPendingPaykitEndpointRemoval()
}
guard isPaykitSceneActive, pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey)
else { return }
await refreshPrivateOnlyPaykitApp()
await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true)
if PaykitSubscriptionNotificationTargetStore.load() == nil {
await presentNextIncomingPaykitItem()
}
}
}

/// One announcement for all offers accepted in a refresh, since each fallback toast would replace the one before it.
private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) {
var counterparties: [String] = []
for offer in offers where !counterparties.contains(where: { PubkyPublicKeyFormat.matches($0, offer.counterparty) }) {
counterparties.append(offer.counterparty)
}
let names = counterparties.map(contactName)
guard let first = names.first else { return }
let title = t("subscriptions__allowance_offer_accepted_title")
let description = names.count == 1
? t("subscriptions__allowance_offer_accepted_description", variables: ["name": first])
: t("subscriptions__allowance_offers_accepted_description", variables: ["names": ListFormatter.localizedString(byJoining: names)])
PaykitAllowanceNotifier.post(title: title, body: description, fallback: {
app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast")
})
}

private func contactName(_ publicKey: String) -> String {
contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName
?? PubkyPublicKeyFormat.displayTruncated(publicKey)
Expand Down
12 changes: 12 additions & 0 deletions Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Foundation
import enum Paykit.PaykitError
import struct Paykit.PubkySessionBootstrapResult
Expand Down Expand Up @@ -145,6 +146,17 @@ class PubkyProfileManager: ObservableObject {

private static func endSessionMutation() {
sessionMutationCount -= 1
if sessionMutationCount == 0 {
sessionSettledSubject.send()
}
}

private static let sessionSettledSubject = PassthroughSubject<Void, Never>()

/// Fires when the last session change in flight ends. `currentSession` reads nil until then, and nothing published
/// changes at that moment, so work that needs the session (a sign-in completing during that change) waits for this.
static var sessionSettledPublisher: AnyPublisher<Void, Never> {
sessionSettledSubject.eraseToAnyPublisher()
}

init(
Expand Down
192 changes: 192 additions & 0 deletions Bitkit/Models/PaykitAllowanceStateBackup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import Foundation

/// The allowance states of a wallet backup by identity. An entry this version can't read is dropped instead of failing the
/// rest of the wallet backup it travels in.
struct PaykitAllowanceStateBackups: Codable {
let byIdentity: [String: PaykitAllowanceStateBackup]

init(_ byIdentity: [String: PaykitAllowanceStateBackup]) {
self.byIdentity = byIdentity
}

init(from decoder: Decoder) throws {
let entries = (try? decoder.singleValueContainer().decode([String: Lenient].self)) ?? [:]
byIdentity = entries.compactMapValues(\.value)
if byIdentity.count != entries.count {
Logger.warn("Skipped \(entries.count - byIdentity.count) unreadable allowance states in a backup", context: "PaykitAllowance")
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(byIdentity)
}

private struct Lenient: Decodable {
let value: PaykitAllowanceStateBackup?

init(from decoder: Decoder) throws {
value = try? PaykitAllowanceStateBackup(from: decoder)
}
}
}

/// One identity's local allowance state in the wallet backup, in the format both apps read: the USD labels of each
/// grant and the attempt journal restart recovery verifies from. Timestamps are RFC 3339, USD amounts decimal strings.
struct PaykitAllowanceStateBackup: Codable {
struct Limits: Codable {
let perPaymentUsd: String
let monthlyUsd: String
let perPaymentSats: UInt64
let monthlySats: UInt64
}

struct Group: Codable {
let id: String
let counterparty: String
let limits: Limits
let allowanceIds: [String]
let createdAt: String
}

struct JournalEntry: Codable {
let attemptId: String
let isAutomatic: Bool
let requestId: PaykitPaymentStateBackup.RequestID
let allowanceId: String?
Comment thread
ovitrif marked this conversation as resolved.
Resolved
let amountSats: UInt64
let paymentEndpointIdentifier: String
let paymentHash: String?
let onchainAddress: String?
let transactionId: String?
let stage: PaykitAllowanceLocalState.Stage
let createdAt: String
let paymentListVersion: UInt64?
let previousPaymentListVersion: UInt64?

/// Android requires every key, so an absent optional is written as null rather than left out.
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(attemptId, forKey: .attemptId)
try container.encode(isAutomatic, forKey: .isAutomatic)
try container.encode(requestId, forKey: .requestId)
try container.encode(allowanceId, forKey: .allowanceId)
try container.encode(amountSats, forKey: .amountSats)
try container.encode(paymentEndpointIdentifier, forKey: .paymentEndpointIdentifier)
try container.encode(paymentHash, forKey: .paymentHash)
try container.encode(onchainAddress, forKey: .onchainAddress)
try container.encode(transactionId, forKey: .transactionId)
try container.encode(stage, forKey: .stage)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(paymentListVersion, forKey: .paymentListVersion)
try container.encode(previousPaymentListVersion, forKey: .previousPaymentListVersion)
}
}

let groups: [Group]
let journal: [JournalEntry]
let presentedProposalIds: Set<String>
let unannouncedOfferIds: Set<String>
let notifiedRequestIds: Set<String>
let lastTrustedTime: String?

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(groups, forKey: .groups)
try container.encode(journal, forKey: .journal)
try container.encode(presentedProposalIds, forKey: .presentedProposalIds)
try container.encode(unannouncedOfferIds, forKey: .unannouncedOfferIds)
try container.encode(notifiedRequestIds, forKey: .notifiedRequestIds)
try container.encode(lastTrustedTime, forKey: .lastTrustedTime)
}

init(_ state: PaykitAllowanceLocalState) {
groups = state.groups.map { group in
Group(
id: group.id,
counterparty: group.counterparty,
limits: Limits(
perPaymentUsd: "\(group.limits.perPaymentUsd)",
monthlyUsd: "\(group.limits.monthlyUsd)",
perPaymentSats: group.limits.perPaymentSats,
monthlySats: group.limits.monthlySats
),
allowanceIds: group.allowanceIds,
createdAt: PaykitAllowanceTime.format(group.createdAt)
)
}
journal = state.journal.map { entry in
JournalEntry(
attemptId: entry.attemptId,
isAutomatic: entry.isAutomatic,
requestId: PaykitPaymentStateBackup.RequestID(entry.requestId, billingPeriod: nil),
allowanceId: entry.allowanceId,
amountSats: entry.amountSats,
paymentEndpointIdentifier: entry.paymentEndpointIdentifier,
paymentHash: entry.paymentHash,
onchainAddress: entry.onchainAddress,
transactionId: entry.transactionId,
stage: entry.stage,
createdAt: PaykitAllowanceTime.format(entry.createdAt),
paymentListVersion: entry.paymentListVersion,
previousPaymentListVersion: entry.previousPaymentListVersion
)
}
presentedProposalIds = state.presentedProposalIds
unannouncedOfferIds = state.unannouncedOfferIds
notifiedRequestIds = state.notifiedRequestIds
lastTrustedTime = state.lastTrustedTime.map(PaykitAllowanceTime.format)
}

func restored() throws -> PaykitAllowanceLocalState {
try PaykitAllowanceLocalState(
groups: groups.map { group in
guard let perPaymentUsd = Decimal(string: group.limits.perPaymentUsd, locale: Locale(identifier: "en_US_POSIX")),
let monthlyUsd = Decimal(string: group.limits.monthlyUsd, locale: Locale(identifier: "en_US_POSIX"))
else { throw Self.invalidBackup("Invalid allowance limits") }
return try PaykitAllowanceLocalState.Group(
id: group.id,
counterparty: group.counterparty,
limits: PaykitAllowanceLimits(
perPaymentUsd: perPaymentUsd,
monthlyUsd: monthlyUsd,
perPaymentSats: group.limits.perPaymentSats,
monthlySats: group.limits.monthlySats
),
allowanceIds: group.allowanceIds,
createdAt: Self.date(group.createdAt)
)
},
journal: journal.map { entry in
try PaykitAllowanceLocalState.JournalEntry(
attemptId: entry.attemptId,
isAutomatic: entry.isAutomatic,
requestId: entry.requestId.restored(billingPeriod: nil),
allowanceId: entry.allowanceId,
amountSats: entry.amountSats,
paymentEndpointIdentifier: entry.paymentEndpointIdentifier,
paymentHash: entry.paymentHash,
onchainAddress: entry.onchainAddress,
transactionId: entry.transactionId,
stage: entry.stage,
createdAt: Self.date(entry.createdAt),
paymentListVersion: entry.paymentListVersion,
previousPaymentListVersion: entry.previousPaymentListVersion
)
},
presentedProposalIds: presentedProposalIds,
unannouncedOfferIds: unannouncedOfferIds,
notifiedRequestIds: notifiedRequestIds,
lastTrustedTime: lastTrustedTime.map(Self.date)
)
}

private static func date(_ value: String) throws -> Date {
guard let date = PaykitAllowanceTime.parse(value) else { throw invalidBackup("Invalid allowance timestamp") }
return date
}

private static func invalidBackup(_ message: String) -> NSError {
NSError(domain: "BackupService", code: -1, userInfo: [NSLocalizedDescriptionKey: message])
}
}
2 changes: 2 additions & 0 deletions Bitkit/Models/PaykitPaymentStateBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ struct PaykitPaymentStateBackup: Codable {
var activeOnchainAttempt: ActiveOnchainAttempt? = nil
var usdt: PaykitUsdtStateBackup? = nil
var acceptedOneTimeRequests: [String: [RequestID]]? = nil
/// Local allowance state by identity, so a restored wallet keeps its USD labels and can still verify open attempts.
var allowances: PaykitAllowanceStateBackups? = nil

struct RequestID: Codable {
let paymentRequestId: String
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@
"security__pin_forgot_text" = "هل نسيت رمز PIN؟ أعد التعيين واستعد محفظة Bitkit باستخدام عبارة الاسترداد. قم بتعيين رمز PIN جديد بعد إكمال الاستعادة.";
"security__pin_forgot_reset" = "إعادة التعيين (يتطلب عبارة الاسترداد)";
"security__pin_send" = "يرجى إدخال رمز PIN الخاص بك لتأكيد وإرسال هذه الدفعة.";
"security__pin_allowance" = "يرجى إدخال رمز PIN الخاص بك لتأكيد هذا التغيير في المخصص.";
"security__pin_send_title" = "أدخل رمز PIN";
"security__pin_use_biometrics" = "استخدام {biometricsName}";
"security__bio" = "المقاييس الحيوية";
Expand Down Expand Up @@ -1635,6 +1636,7 @@
"subscriptions__allowance_executed_description" = "أرسل الدفع التلقائي {amount} إلى {name}";
"subscriptions__allowance_offer_accepted_title" = "تمت إضافة مخصص";
"subscriptions__allowance_offer_accepted_description" = "أعدّ {name} مخصصًا لك";
"subscriptions__allowance_offers_accepted_description" = "أعدّ {names} مخصصات لك";
"subscriptions__allowance_limit_title" = "تم بلوغ الحد";
"subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع.";
"subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا";
Loading
Loading