diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5e17f7792..47a956ba2 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -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 @@ -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, @@ -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() @@ -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) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 52b60f8cf..65fbb6bfb 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import enum Paykit.PaykitError import struct Paykit.PubkySessionBootstrapResult @@ -145,6 +146,17 @@ class PubkyProfileManager: ObservableObject { private static func endSessionMutation() { sessionMutationCount -= 1 + if sessionMutationCount == 0 { + sessionSettledSubject.send() + } + } + + private static let sessionSettledSubject = PassthroughSubject() + + /// 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 { + sessionSettledSubject.eraseToAnyPublisher() } init( diff --git a/Bitkit/Models/PaykitAllowanceStateBackup.swift b/Bitkit/Models/PaykitAllowanceStateBackup.swift new file mode 100644 index 000000000..74bb18233 --- /dev/null +++ b/Bitkit/Models/PaykitAllowanceStateBackup.swift @@ -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? + 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 + let unannouncedOfferIds: Set + let notifiedRequestIds: Set + 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]) + } +} diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 48763f950..9e631a952 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -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 diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index b0269c7fd..631e2f3cb 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -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" = "المقاييس الحيوية"; @@ -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" = "مدفوع تلقائيًا"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 29eec5810..8081adc6d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -613,6 +613,7 @@ "security__pin_forgot_text" = "Has oblidat el PIN? Restableix i recupera la teva cartera Bitkit amb la teva frase de recuperació. Configura un nou PIN després de completar la recuperació."; "security__pin_forgot_reset" = "Restablir (Es necessita la frase de recuperació)"; "security__pin_send" = "Si us plau, introdueix el teu codi PIN per confirmar i enviar aquest pagament."; +"security__pin_allowance" = "Si us plau, introdueix el teu codi PIN per confirmar aquest canvi de l'assignació."; "security__pin_send_title" = "Introdueix el codi PIN"; "security__pin_use_biometrics" = "Utilitzar {biometricsName}"; "security__bio" = "Biometria"; @@ -1819,6 +1820,7 @@ "subscriptions__allowance_executed_description" = "El pagament automàtic ha enviat {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Assignació afegida"; "subscriptions__allowance_offer_accepted_description" = "{name} ha configurat una assignació per a tu"; +"subscriptions__allowance_offers_accepted_description" = "{names} han configurat assignacions per a tu"; "subscriptions__allowance_limit_title" = "Límit assolit"; "subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar."; "subscriptions__allowance_auto_paid" = "Pagat automàticament"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 8d813a89d..a6e99c7a1 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -655,6 +655,7 @@ "security__pin_forgot_text" = "Zapomněli jste svůj PIN? Resetujte a obnovte svou peněženku Bitkit pomocí vaší obnovovací fráze. Po dokončení obnovení nastavte nový PIN."; "security__pin_forgot_reset" = "Resetovat (vyžaduje obnovovací frázi)"; "security__pin_send" = "Pro potvrzení a odeslání této platby prosím zadejte svůj PIN kód."; +"security__pin_allowance" = "Pro potvrzení této změny povolení plateb prosím zadejte svůj PIN kód."; "security__pin_send_title" = "Zadejte kód PIN"; "security__pin_use_biometrics" = "Použít {biometricsName}"; "security__bio" = "Biometrické údaje"; @@ -1958,6 +1959,7 @@ "subscriptions__allowance_executed_description" = "Automatická platba odeslala {amount} uživateli {name}"; "subscriptions__allowance_offer_accepted_title" = "Povolení plateb přidáno"; "subscriptions__allowance_offer_accepted_description" = "Uživatel {name} vám nastavil povolení plateb"; +"subscriptions__allowance_offers_accepted_description" = "Uživatelé {names} vám nastavili povolení plateb"; "subscriptions__allowance_limit_title" = "Limit dosažen"; "subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte."; "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index cfcd3dccc..1f411bc91 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -653,6 +653,7 @@ "security__pin_forgot_text" = "PIN vergessen? Setze hier dein Bitkit-Wallet zurück und stelle es mit deiner Wiederherstellungsphrase wieder her. Vergebe eine neue PIN nach deiner Wiederherstellung."; "security__pin_forgot_reset" = "Zurücksetzen (erfordert Wiederherstellungsphrase)"; "security__pin_send" = "Bitte gib deinen PIN-Code ein, um diese Zahlung zu bestätigen und zu senden."; +"security__pin_allowance" = "Bitte gib deinen PIN-Code ein, um diese Änderung des Kontingents zu bestätigen."; "security__pin_send_title" = "PIN-Code eingeben"; "security__pin_use_biometrics" = "Verwende {biometricsName}"; "security__bio" = "Biometrie"; @@ -1955,6 +1956,7 @@ "subscriptions__allowance_executed_description" = "Auto-Pay hat {amount} an {name} gesendet"; "subscriptions__allowance_offer_accepted_title" = "Kontingent hinzugefügt"; "subscriptions__allowance_offer_accepted_description" = "{name} hat ein Kontingent für dich eingerichtet"; +"subscriptions__allowance_offers_accepted_description" = "{names} haben Kontingente für dich eingerichtet"; "subscriptions__allowance_limit_title" = "Limit erreicht"; "subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen."; "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index f4bc31454..b5ef9c7f2 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -569,6 +569,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" = "Βιομετρικά"; @@ -1690,6 +1691,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" = "Πληρώθηκε αυτόματα"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 5a3c17e2f..e9d820535 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -644,6 +644,7 @@ "security__pin_forgot_text" = "Forgot your PIN? Reset and recover your Bitkit wallet with your recovery phrase. Set a new PIN after completing recovery."; "security__pin_forgot_reset" = "Reset (Requires Recovery Phrase)"; "security__pin_send" = "Please enter your PIN code to confirm and send out this payment."; +"security__pin_allowance" = "Please enter your PIN code to confirm this allowance change."; "security__pin_send_title" = "Enter PIN Code"; "security__pin_use_biometrics" = "Use {biometricsName}"; "security__bio" = "Biometrics"; @@ -1832,6 +1833,7 @@ "subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay."; "subscriptions__allowance_offer_accepted_title" = "Allowance Added"; "subscriptions__allowance_offer_accepted_description" = "{name} set up an allowance for you"; +"subscriptions__allowance_offers_accepted_description" = "{names} set up allowances for you"; "subscriptions__allowance_auto_paid" = "Auto-paid"; // On-chain acceptance and original-payment follow-up diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 4a6cc334b..b8c87d4a5 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -656,6 +656,7 @@ "security__pin_forgot_text" = "¿Olvidaste tu PIN? Restablece y recupera tu billetera de Bitkit con tu frase de recuperación. Establece un nuevo PIN al finalizar."; "security__pin_forgot_reset" = "Resetear (requiere frase de recuperación)"; "security__pin_send" = "Por favor, introduzca su código PIN para confirmar y enviar este pago."; +"security__pin_allowance" = "Por favor, introduzca su código PIN para confirmar este cambio en la asignación."; "security__pin_send_title" = "Introduzca su PIN"; "security__pin_use_biometrics" = "Usar {biometricsName}"; "security__bio" = "Biométricos"; @@ -1961,6 +1962,7 @@ "subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Asignación agregada"; "subscriptions__allowance_offer_accepted_description" = "{name} configuró una asignación para ti"; +"subscriptions__allowance_offers_accepted_description" = "{names} configuraron asignaciones para ti"; "subscriptions__allowance_limit_title" = "Límite alcanzado"; "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 47cd807dc..8061c612d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -614,6 +614,7 @@ "security__pin_forgot_text" = "¿Ha olvidado su PIN? Reinicie y recupere su monedero Bitkit con su frase de recuperación. Establezca un nuevo PIN después de completar la recuperación."; "security__pin_forgot_reset" = "Restablecer (requiere frase de recuperación)"; "security__pin_send" = "Por favor, introduzca su código PIN para confirmar y enviar este pago."; +"security__pin_allowance" = "Por favor, introduzca su código PIN para confirmar este cambio en la asignación."; "security__pin_send_title" = "Introduzca el Código PIN"; "security__pin_use_biometrics" = "Utilizar {biometricsName}"; "security__bio" = "Biometría"; @@ -1839,6 +1840,7 @@ "subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Asignación añadida"; "subscriptions__allowance_offer_accepted_description" = "{name} ha configurado una asignación para ti"; +"subscriptions__allowance_offers_accepted_description" = "{names} han configurado asignaciones para ti"; "subscriptions__allowance_limit_title" = "Límite alcanzado"; "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 2535a6350..359fcae29 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -656,6 +656,7 @@ "security__pin_forgot_text" = "Vous avez oublié votre code PIN ? Réinitialisez et récupérez votre portefeuille Bitkit avec votre phrase de récupération. Définissez un nouveau code PIN après avoir terminé la récupération."; "security__pin_forgot_reset" = "Réinitialisation (nécessite une phrase de récupération)"; "security__pin_send" = "Veuillez saisir votre code PIN pour confirmer et envoyer ce paiement."; +"security__pin_allowance" = "Veuillez saisir votre code PIN pour confirmer cette modification de l'allocation."; "security__pin_send_title" = "Entrer code PIN"; "security__pin_use_biometrics" = "Utiliser {biometricsName}"; "security__bio" = "Biométrie"; @@ -1969,6 +1970,7 @@ "subscriptions__allowance_executed_description" = "Le paiement automatique a envoyé {amount} à {name}"; "subscriptions__allowance_offer_accepted_title" = "Allocation ajoutée"; "subscriptions__allowance_offer_accepted_description" = "{name} a configuré une allocation pour vous"; +"subscriptions__allowance_offers_accepted_description" = "{names} ont configuré des allocations pour vous"; "subscriptions__allowance_limit_title" = "Limite atteinte"; "subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer."; "subscriptions__allowance_auto_paid" = "Payé automatiquement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 8b73e25a9..f5dae27b5 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -647,6 +647,7 @@ "security__pin_forgot_text" = "Ha dimenticato il PIN? Resetta e recupera il tuo wallet Bitkit con la recovery phrase. Imposta un nuovo PIN dopo aver completato il recupero."; "security__pin_forgot_reset" = "Reset (Richiede la Recovery Phrase)"; "security__pin_send" = "Inserisci il codice PIN per confermare e inviare questo pagamento."; +"security__pin_allowance" = "Inserisci il codice PIN per confermare questa modifica del Plafond."; "security__pin_send_title" = "Inserisci Codice PIN"; "security__pin_use_biometrics" = "Utilizza {biometricsName}"; "security__bio" = "Biometria"; @@ -1928,6 +1929,7 @@ "subscriptions__allowance_executed_description" = "Il pagamento automatico ha inviato {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Plafond Aggiunto"; "subscriptions__allowance_offer_accepted_description" = "{name} ha impostato un Plafond per te"; +"subscriptions__allowance_offers_accepted_description" = "{names} hanno impostato dei Plafond per te"; "subscriptions__allowance_limit_title" = "Limite Raggiunto"; "subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare."; "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 1bf09b447..773195473 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -653,6 +653,7 @@ "security__pin_forgot_text" = "Uw pincode vergeten? Reset en herstel uw Bitkit wallet met de herstelwoorden. Na het herstel kunt u een niewe pincode configureren."; "security__pin_forgot_reset" = "Reset (Vereist de Herstelwooden)"; "security__pin_send" = "Voer uw pincode in om deze betaling te bevestigen en versturen."; +"security__pin_allowance" = "Voer uw pincode in om deze wijziging van de toelage te bevestigen."; "security__pin_send_title" = "Voer Pincode In"; "security__pin_use_biometrics" = "Gebruik {biometricsName}"; "security__bio" = "Biometrie"; @@ -1963,6 +1964,7 @@ "subscriptions__allowance_executed_description" = "Automatisch betalen heeft {amount} naar {name} gestuurd"; "subscriptions__allowance_offer_accepted_title" = "Toelage toegevoegd"; "subscriptions__allowance_offer_accepted_description" = "{name} heeft een toelage voor je ingesteld"; +"subscriptions__allowance_offers_accepted_description" = "{names} hebben toelagen voor je ingesteld"; "subscriptions__allowance_limit_title" = "Limiet bereikt"; "subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen."; "subscriptions__allowance_auto_paid" = "Automatisch betaald"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1df98c9fd..fbd928fab 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -655,6 +655,7 @@ "security__pin_forgot_text" = "Zapomniałeś kodu PIN? Zresetuj i odzyskaj swój portfel Bitkit używając mnemonika. Ustaw nowy kod PIN po zakończeniu odzyskiwania portfela."; "security__pin_forgot_reset" = "Reset (wymaga mnemonika)"; "security__pin_send" = "Proszę podaj swój kod PIN aby potwierdzić i wysłać płatność."; +"security__pin_allowance" = "Proszę podaj swój kod PIN aby potwierdzić tę zmianę przydziału."; "security__pin_send_title" = "Wprowadź kod PIN"; "security__pin_use_biometrics" = "Użyj {biometricsName}"; "security__bio" = "Biometria"; @@ -1968,6 +1969,7 @@ "subscriptions__allowance_executed_description" = "Autopłatność wysłała {amount} do {name}"; "subscriptions__allowance_offer_accepted_title" = "Przydział dodany"; "subscriptions__allowance_offer_accepted_description" = "Użytkownik {name} ustawił dla Ciebie przydział"; +"subscriptions__allowance_offers_accepted_description" = "Użytkownicy {names} ustawili dla Ciebie przydziały"; "subscriptions__allowance_limit_title" = "Osiągnięto limit"; "subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić."; "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 6b4158f28..73018ee91 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -655,6 +655,7 @@ "security__pin_forgot_text" = "Esqueceu seu PIN? Redefina e recupere sua carteira Bitkit com a sua frase de recuperação. Defina um novo PIN após concluir a recuperação."; "security__pin_forgot_reset" = "Restaurar (Requer a Frase de Recuperação)"; "security__pin_send" = "Digite seu código PIN para confirmar e enviar esse pagamento."; +"security__pin_allowance" = "Digite seu código PIN para confirmar esta alteração da pré-aprovação."; "security__pin_send_title" = "Digitar o código PIN"; "security__pin_use_biometrics" = "Usar {biometricsName}"; "security__bio" = "Biometria"; @@ -1969,6 +1970,7 @@ "subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; "subscriptions__allowance_offer_accepted_title" = "Pré-aprovação Adicionada"; "subscriptions__allowance_offer_accepted_description" = "{name} configurou uma pré-aprovação para você"; +"subscriptions__allowance_offers_accepted_description" = "{names} configuraram pré-aprovações para você"; "subscriptions__allowance_limit_title" = "Limite Atingido"; "subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 322ddc8fb..164fcff19 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -590,6 +590,7 @@ "security__pin_forgot_text" = "Esqueceu seu PIN? Redefina e recupere sua carteira Bitkit com a sua frase de recuperação. Defina um novo PIN após concluir a recuperação."; "security__pin_forgot_reset" = "Restaurar (Requer a Frase de Recuperação)"; "security__pin_send" = "Digite seu código PIN para confirmar e enviar esse pagamento."; +"security__pin_allowance" = "Digite seu código PIN para confirmar esta alteração da pré-aprovação."; "security__pin_send_title" = "Digitar o código PIN"; "security__pin_use_biometrics" = "Usar {biometricsName}"; "security__bio" = "Biometria"; @@ -1615,6 +1616,7 @@ "subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; "subscriptions__allowance_offer_accepted_title" = "Pré-aprovação adicionada"; "subscriptions__allowance_offer_accepted_description" = "{name} configurou uma pré-aprovação para si"; +"subscriptions__allowance_offers_accepted_description" = "{names} configuraram pré-aprovações para si"; "subscriptions__allowance_limit_title" = "Limite atingido"; "subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c7d64f169..f4cb13072 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -650,6 +650,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" = "Биометрия"; @@ -1949,6 +1950,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" = "Оплачено автоматически"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index f9e40f1e4..43d924480 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -1083,6 +1083,7 @@ final class WalletBackupChangeTracker { PrivatePaykitService.walletBackupDataChangedPublisher, PaykitSubscriptionStateStore.walletBackupDataChangedPublisher, PaykitPaymentRequestIdStore.walletBackupDataChangedPublisher, + PaykitAllowanceKeychainStore.walletBackupDataChangedPublisher, PaykitPaymentProofService.proofStateChangedPublisher, OnchainSendAttemptStore.walletBackupDataChangedPublisher, UsdtWalletManager.walletBackupDataChangedPublisher, diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 812f41938..0d33aa32d 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -87,6 +87,8 @@ struct PaykitAllowanceLocalState: Codable, Equatable { var groups: [Group] = [] var journal: [JournalEntry] = [] var presentedProposalIds: Set = [] + /// Offers this wallet accepts by itself, marked before the accept, until "Allowance Added" is shown for them. + var unannouncedOfferIds: Set = [] var notifiedRequestIds: Set = [] var lastTrustedTime: Date? @@ -104,14 +106,58 @@ protocol PaykitAllowanceStoring: Sendable { struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { private typealias Stored = [String: PaykitAllowanceLocalState] + private static let backupChanged = PassthroughSubject() + static var walletBackupDataChangedPublisher: AnyPublisher { + backupChanged.eraseToAnyPublisher() + } + func load(identity: String) throws -> PaykitAllowanceLocalState { try loadAll()[identity] ?? PaykitAllowanceLocalState() } func save(_ state: PaykitAllowanceLocalState, identity: String) throws { var all = try loadAll() + let previous = all[identity] all[identity] = state try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + // The trusted-time watermark moves on every eligibility check; only a change to the rest needs a new backup. + var unchanged = state + unchanged.lastTrustedTime = previous?.lastTrustedTime + if unchanged != previous { + Self.backupChanged.send() + } + } + + /// Nil when the state can't be read, so the rest of the wallet backup still goes out. + func backupSnapshot() -> PaykitAllowanceStateBackups? { + do { + var byIdentity: [String: PaykitAllowanceStateBackup] = [:] + for (identity, state) in try loadAll() { + byIdentity[PubkyPublicKeyFormat.normalized(identity) ?? identity] = PaykitAllowanceStateBackup(state) + } + return PaykitAllowanceStateBackups(byIdentity) + } catch { + Logger.warn("Left allowance state out of the wallet backup: \(error)", context: "PaykitAllowance") + return nil + } + } + + /// Restores each identity's state it can read over the local one; identities the backup lacks keep theirs. A failure + /// here never stops the rest of the wallet restore. + func restoreBackup(_ snapshot: PaykitAllowanceStateBackups) { + var all = (try? loadAll()) ?? [:] + for (identity, backup) in snapshot.byIdentity { + do { + all[PubkyPublicKeyFormat.normalized(identity) ?? identity] = try backup.restored() + } catch { + Logger.warn("Skipped an unreadable allowance state in a backup: \(error)", context: "PaykitAllowance") + } + } + do { + try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + } catch { + Logger.warn("Failed to restore allowance state: \(error)", context: "PaykitAllowance") + } } private func loadAll() throws -> Stored { @@ -128,13 +174,21 @@ struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { /// The side effects of paying one request, behind a protocol so the admission logic is testable without a node. protocol PaykitAllowancePaying: Sendable { func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? - func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws + /// Consumes the list for the journaled attempt `owner`, only while the list consumed so far is `expectedPreviousVersion`. + func consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + owner: String, + expectedPreviousVersion: UInt64? + ) async throws func resolvePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID, outcome: PrivatePaymentListSendOutcome) async func claimForExecution(_ request: PaykitPaymentRequest) async throws - /// The list version a consumption replaced, read while its in-memory record still exists. - func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? - /// Rolls back a list a failed automatic payment consumed, when it is still the contact's consumed version. - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async + /// The contact's list version consumed so far. + func consumedPaymentListVersion(publicKey: String) async -> UInt64? + /// Rolls back a list a failed automatic payment consumed, when `owner` consumed the contact's current version. + /// Returns false only when the rollback could not be saved, so recovery tries it again. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) async -> Bool /// Keeps this device the local owner of the request across the automatic Acceptance, as the manual flow does, so a /// request that was accepted but never paid comes back to the payer as an ordinary Payment Request. /// Returns whether this call added the intent, so a refused acceptance never drops one the manual flow saved. @@ -155,8 +209,20 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PrivatePaykitService.shared.resolveAllowancePayment(request, eligibleIdentifiers: eligibleIdentifiers) } - func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { - try await PrivatePaykitService.shared.consumePrivatePaymentList(publicKey: publicKey, context: context, attemptId: attemptId) + func consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + owner: String, + expectedPreviousVersion: UInt64? + ) async throws { + try await PrivatePaykitService.shared.consumePrivatePaymentList( + publicKey: publicKey, + context: context, + attemptId: attemptId, + owner: owner, + expectedPreviousVersion: .some(expectedPreviousVersion) + ) } func resolvePaymentList( @@ -181,15 +247,22 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PaykitPaymentRequestService().claimForPayment(request) } - func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { - await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey, attemptId: attemptId) + func consumedPaymentListVersion(publicKey: String) async -> UInt64? { + await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey) } - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) async -> Bool { do { - try await PrivatePaykitService.shared.releaseConsumedPaymentList(publicKey: publicKey, version: version, previousVersion: previousVersion) + try await PrivatePaykitService.shared.releaseConsumedPaymentList( + publicKey: publicKey, + version: version, + previousVersion: previousVersion, + owner: owner + ) + return true } catch { Logger.warn("Failed to release a payment list a failed automatic payment consumed: \(error)", context: "PaykitAllowance") + return false } } @@ -339,6 +412,12 @@ actor PaykitAllowanceExecutor { } } + func checkedUpdateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) throws -> Void) throws { + var state = try store.load(identity: identity) + try change(&state) + try store.save(state, identity: identity) + } + func isHandling(_ requestId: PaykitPaymentRequest.ID) -> Bool { inFlightRequestIds.contains(requestId) } @@ -370,7 +449,8 @@ actor PaykitAllowanceExecutor { let history = current?.history ?? Paykit.AllowanceAccountingHistory(associations: [], occurrences: [], watermarks: []) var outcomes: [Paykit.PaymentOutcomeReport] = [] for attempt in history.occurrences.flatMap(\.attempts) { - if let outcome = await verifiedOutcome(for: attempt, identity: identity) { + // Throws until the node has its payment history, so a restore reconciles only once the node can answer. + if let outcome = try await verifiedOutcome(for: attempt, identity: identity) { outcomes.append(Paykit.PaymentOutcomeReport(attemptId: attempt.attemptId, outcome: outcome)) } } @@ -392,35 +472,52 @@ actor PaykitAllowanceExecutor { do { // No ledger means nothing was ever admitted, so there is nothing to recover and no reason to create one at launch. guard try await sdk.allowanceAccountingState() != nil else { return } + // Needs no node, so it runs before reconciliation, which waits for the node's payment history. + await retryPaymentListReleases(identity: identity) let state = try await ensureReconciled(identity: identity) // One journal read for the whole pass: each attempt appears once, so a stage changed below is never read again. let journalStages = Dictionary( localState(identity: identity).journal.map { ($0.attemptId, $0.stage) }, uniquingKeysWith: { first, _ in first } ) - for attempt in state.history.occurrences.flatMap(\.attempts) { - switch attempt.status { - case .prepared: - guard attempt.epoch == state.epoch else { continue } - try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) - case .submitted, .unknown: - guard let outcome = await verifiedOutcome(for: attempt, identity: identity) else { - if attempt.status == .submitted { - try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) + for occurrence in state.history.occurrences { + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: occurrence.key.request.paymentRequestId, + counterparty: occurrence.key.request.counterparty + ) + // A payment in progress owns its attempts; checked per occurrence, since one can start while this loop awaits. + guard !inFlightRequestIds.contains(requestId) else { continue } + for attempt in occurrence.attempts { + switch attempt.status { + case .prepared: + guard attempt.epoch == state.epoch else { continue } + try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) + case .submitted, .unknown: + let verified: Paykit.PaymentOutcome? + do { + verified = try await verifiedOutcome(for: attempt, identity: identity) + } catch { + // Left as it is until the node can answer; recovery runs again once it is running. + continue + } + guard let outcome = verified else { + if attempt.status == .submitted { + try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) + } + continue + } + try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) + case .succeeded, .failed: + // Reconciliation can settle an attempt the journal still shows as open. + let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed + // The cached stage only skips settled attempts cheaply; a settlement while this loop awaited may have + // settled one since, so the move itself checks the current stage. + if let stage = journalStages[attempt.attemptId], !stage.isTerminal, + let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), + outcome == .failed + { + await releaseConsumedPaymentList(of: entry, identity: identity) } - continue - } - try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) - case .succeeded, .failed: - // Reconciliation can settle an attempt the journal still shows as open. - let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed - // The cached stage only skips settled attempts cheaply; a settlement while this loop awaited may have - // settled one since, so the move itself checks the current stage. - if let stage = journalStages[attempt.attemptId], !stage.isTerminal, - let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), - outcome == .failed - { - await releaseConsumedPaymentList(of: entry) } } } @@ -453,7 +550,10 @@ actor PaykitAllowanceExecutor { return true } - private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async -> Paykit.PaymentOutcome? { + private struct NodeHistoryUnavailable: Error {} + + /// Throws `NodeHistoryUnavailable` when the answer depends on a node that has no payment history yet. + private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async throws -> Paykit.PaymentOutcome? { switch attempt.status { case .prepared: return .failed @@ -468,8 +568,19 @@ actor PaykitAllowanceExecutor { } switch entry.stage { case .prepared, .submitted: - // The journal is written before the node call, so no payment left this wallet. - return .failed + // The live journal is written before the node call, but a restored snapshot may predate the send, + // so a node that knows the payment decides. + guard let paymentHash = entry.paymentHash else { return .failed } + switch await lightningLookup.status(paymentHash: paymentHash) { + case .succeeded: + return .succeeded + case .pending: + return nil + case .failed, .unknown: + return .failed + case .unavailable: + throw NodeHistoryUnavailable() + } case .succeeded: return .succeeded case .failed: @@ -486,6 +597,8 @@ actor PaykitAllowanceExecutor { return .failed case .pending, .unknown: return nil + case .unavailable: + throw NodeHistoryUnavailable() } } return entry.transactionId == nil ? nil : .succeeded @@ -493,24 +606,40 @@ actor PaykitAllowanceExecutor { private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) - let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) + // A settlement and recovery can both record one failure; only the call that ends the attempt releases its list. + let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity, onlyIfOpen: outcome == .failed) if outcome == .failed { - await releaseConsumedPaymentList(of: entry) + await releaseConsumedPaymentList(of: entry, identity: identity) } } - /// A failed automatic payment leaves the payee's list usable for the manual payment that follows. - private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?) async { + /// A failed automatic payment leaves the payee's list usable for the manual payment that follows. The journal keeps + /// the version until the release is saved, so recovery retries one that could not be. + private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?, identity: String) async { guard let entry, entry.isAutomatic, let version = entry.paymentListVersion else { return } - await payer.releaseConsumedPaymentList( + let released = await payer.releaseConsumedPaymentList( publicKey: entry.requestId.counterparty, version: version, - previousVersion: entry.previousPaymentListVersion + previousVersion: entry.previousPaymentListVersion, + owner: entry.attemptId ) + guard released else { return } + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == entry.attemptId }) else { return } + state.journal[index].paymentListVersion = nil + state.journal[index].previousPaymentListVersion = nil + } + } + + private func retryPaymentListReleases(identity: String) async { + for entry in localState(identity: identity).journal where entry.stage == .failed && entry.paymentListVersion != nil { + await releaseConsumedPaymentList(of: entry, identity: identity) + } } /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, - /// so a settlement event and recovery never both raise Payment Executed. + /// so a settlement event and recovery never both raise Payment Executed. Returns nil when the move can't be saved: + /// the entry stays open, so a later settlement or recovery acts on it once instead of twice. @discardableResult private func applyJournalOutcome( attemptId: String, @@ -520,19 +649,26 @@ actor PaykitAllowanceExecutor { ) -> PaykitAllowanceLocalState.JournalEntry? { var entry: PaykitAllowanceLocalState.JournalEntry? var settledEntry: PaykitAllowanceLocalState.JournalEntry? - updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), - !onlyIfOpen || !state.journal[index].stage.isTerminal - else { return } - entry = state.journal[index] - if outcome == .succeeded, state.journal[index].stage != .succeeded { - settledEntry = state.journal[index] - } - switch outcome { - case .succeeded: state.journal[index].stage = .succeeded - case .failed: state.journal[index].stage = .failed - case .unknown: state.journal[index].stage = .unknown + do { + var state = try store.load(identity: identity) + if let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), + !onlyIfOpen || !state.journal[index].stage.isTerminal + { + entry = state.journal[index] + if outcome == .succeeded, state.journal[index].stage != .succeeded { + settledEntry = state.journal[index] + } + switch outcome { + case .succeeded: state.journal[index].stage = .succeeded + case .failed: state.journal[index].stage = .failed + case .unknown: state.journal[index].stage = .unknown + } + try store.save(state, identity: identity) } + } catch { + Logger.warn("Failed to save an allowance attempt's outcome: \(error)", context: "PaykitAllowance") + entry = nil + settledEntry = nil } Self.eventSubject.send(.ledgerChanged) if let entry = settledEntry, entry.isAutomatic, let paymentHash = entry.paymentHash { @@ -548,8 +684,37 @@ actor PaykitAllowanceExecutor { return PaykitAllowanceCapacity.attempts(from: state.history) } - func succeededAutomaticPayments(identity: String) -> [PaykitAllowanceLocalState.JournalEntry] { - localState(identity: identity).journal.filter { $0.isAutomatic && $0.stage == .succeeded } + struct AutomaticPayment: Equatable { + let requestId: PaykitPaymentRequest.ID + let allowanceId: String + let amountSats: UInt64 + } + + /// Read from the SDK ledger, which keeps every attempt, rather than the journal, which keeps only the newest ones. + /// Nil when the ledger can't be read, so the caller keeps what it showed. + func succeededAutomaticPayments(identity: String) async -> [AutomaticPayment]? { + let state: Paykit.AllowanceAccountingState? + do { + state = try await sdk.allowanceAccountingState() + } catch { + Logger.warn("Failed to read allowance accounting for automatic payments: \(error)", context: "PaykitAllowance") + return nil + } + guard let state else { return [] } + return state.history.occurrences.filter { + PubkyPublicKeyFormat.matches($0.key.request.localPublicKey, identity) + }.flatMap { occurrence in + occurrence.attempts.compactMap { attempt in + guard attempt.mode == .automatic, attempt.status == .succeeded, let allowanceId = attempt.allowanceId, + let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value()) + else { return nil } + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: occurrence.key.request.paymentRequestId, + counterparty: occurrence.key.request.counterparty + ) + return AutomaticPayment(requestId: requestId, allowanceId: allowanceId, amountSats: amountSats) + } + } } // MARK: Automatic payment @@ -720,15 +885,26 @@ actor PaykitAllowanceExecutor { let listAttemptId = UUID() do { - try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: listAttemptId) - if let consumedVersion = payment.context.paymentListVersion { - let previousVersion = await payer.previousPaymentListVersion(publicKey: request.counterparty, attemptId: listAttemptId) - updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { return } - state.journal[index].paymentListVersion = consumedVersion + let previousVersion = await payer.consumedPaymentListVersion(publicKey: request.counterparty) + if let listVersion = payment.context.paymentListVersion { + // Journaled before the list is consumed, so recovery gives it back after a restart at any point; the list + // records this attempt as its owner, so recovery never gives back one this attempt did not consume. + try checkedUpdateLocalState(identity: identity) { state in + // An attempt whose journal entry was never saved could not be recovered, so its list is not consumed. + guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { + throw JournalEntryMissing() + } + state.journal[index].paymentListVersion = listVersion state.journal[index].previousPaymentListVersion = previousVersion } } + try await payer.consumePaymentList( + publicKey: request.counterparty, + context: payment.context, + attemptId: listAttemptId, + owner: submitted.attemptId, + expectedPreviousVersion: previousVersion + ) try await payer.ensurePaymentAllowed(request) try await payer.prepareProof( request, @@ -738,10 +914,7 @@ actor PaykitAllowanceExecutor { ) } catch { await payer.cancelProofPreparation(request) - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: submitted.attemptId, identity: identity, - outcome: .definitePreBroadcastFailure - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: submitted.attemptId, outcome: .failed, identity: identity) throw error } @@ -760,19 +933,8 @@ actor PaykitAllowanceExecutor { _ request: PaykitPaymentRequest, payment: PrivatePaykitAllowancePayment, attemptId: UUID, - journalAttemptId: String, - identity: String, outcome: PrivatePaymentListSendOutcome ) async { - if outcome == .definitePreBroadcastFailure { - // Released here, so recording the attempt failed must not release it again: by then another payment may - // have consumed the same list version. - updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == journalAttemptId }) else { return } - state.journal[index].paymentListVersion = nil - state.journal[index].previousPaymentListVersion = nil - } - } await payer.resolvePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: attemptId, outcome: outcome) } @@ -789,10 +951,7 @@ actor PaykitAllowanceExecutor { try await payer.associateLightningPayment(request, paymentHash: paymentHash) } catch { await payer.cancelProofPreparation(request) - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, - outcome: .definitePreBroadcastFailure - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } @@ -807,24 +966,17 @@ actor PaykitAllowanceExecutor { guard await payer.failLightningPayment(paymentHash: paymentHash, submissionError: error) else { // The node may have started the payment; its settlement event records the outcome. Logger.warn("Automatic Lightning allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) if !hasTerminalStage(attemptId: attemptId, identity: identity) { try await record(attemptId: attemptId, outcome: .unknown, identity: identity) } return .started } - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, - outcome: .definitePreBroadcastFailure - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) setStage(.sent, attemptId: attemptId, identity: identity) Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance") return .started @@ -953,6 +1105,8 @@ actor PaykitAllowanceExecutor { ) } + private struct JournalEntryMissing: Error {} + private func journal(_ entry: PaykitAllowanceLocalState.JournalEntry, identity: String) { updateLocalState(identity: identity) { state in state.journal.removeAll { $0.attemptId == entry.attemptId } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index e813a76a5..3b6827861 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -69,6 +69,8 @@ final class PaykitAllowanceManager { @ObservationIgnored private var identity: String? @ObservationIgnored private var isProcessingRequests = false @ObservationIgnored private var isAcceptingOffers = false + /// Offers already returned for announcement, so a mark whose clear failed to save is not announced again on every poll. + @ObservationIgnored private var announcedOfferIds: Set = [] private var updateDepth = 0 /// Whether allowances are being refreshed or a pass is deciding requests, so a covered request may still change hands. @@ -130,11 +132,18 @@ final class PaykitAllowanceManager { await executor.activate(identity: identity) if identityChanged { manualRequestIds = [:] + announcedOfferIds = [] await executor.recover(identity: identity) } await refresh() } + /// Settles open attempts again, for the ones the node could not answer for before it was running. + func recoverOpenAttempts() async { + guard let identity else { return } + await executor.recover(identity: identity) + } + func deactivate() { identity = nil Task { await executor.activate(identity: nil) } @@ -143,6 +152,7 @@ final class PaykitAllowanceManager { autoPaidRequestIds = [] autoPaidSatsByScopedId = [:] manualRequestIds = [:] + announcedOfferIds = [] } func refresh() async { @@ -160,11 +170,11 @@ final class PaykitAllowanceManager { Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance") } localState = await executor.localState(identity: identity) - let paid = await executor.succeededAutomaticPayments(identity: identity) + guard let paid = await executor.succeededAutomaticPayments(identity: identity) else { return } autoPaidRequestIds = Set(paid.map(\.requestId)) - autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, entry in - guard let allowanceId = entry.allowanceId else { return } - totals[PaykitAllowance.scopedId(counterparty: entry.requestId.counterparty, allowanceId: allowanceId), default: 0] += entry.amountSats + autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, payment in + totals[PaykitAllowance.scopedId(counterparty: payment.requestId.counterparty, allowanceId: payment.allowanceId), default: 0] += + payment.amountSats } } @@ -175,7 +185,9 @@ final class PaykitAllowanceManager { /// Whether an incoming request is covered by an active Allowance this wallet granted. A request created before the /// Allowance was accepted stays manual: it may already have been shown to the user for a decision. func coversRequest(_ request: PaykitPaymentRequest) -> Bool { - guard request.isAllowancePayable else { return false } + guard request.isAllowancePayable, + request.acceptedPaymentEndpointIdentifiers.contains(where: Self.automaticPaymentEndpointIdentifiers.contains) + else { return false } return allowances.contains { allowance in guard allowance.isAllower, allowance.status(at: now()) == .active, PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty), let acceptedAt = allowance.lastEventAt, @@ -257,33 +269,56 @@ final class PaykitAllowanceManager { /// Accepts the offers that make this wallet the allowee, and returns the ones now active so the caller can tell the /// user. A failed accept stays unanswered and is tried again on the next refresh. `canContinue` gates each further - /// accept; an accept already made is still returned while this identity is active and `canAnnounce` holds, since - /// the offer is no longer answerable afterwards and nothing else would tell the user about it. + /// accept. Each offer is marked before its accept and unmarked only when returned, so an accept whose announcement a + /// stop or a cancelled refresh cut short is returned by a later call, while this identity is active and `canAnnounce` + /// holds: the offer is no longer answerable afterwards and nothing else would tell the user about it. func acceptOffersFromAllowers( canContinue: @MainActor () -> Bool = { true }, canAnnounce: @MainActor () -> Bool = { true } ) async -> [PaykitAllowanceEntry] { guard let identity, !isAcceptingOffers else { return [] } + isAcceptingOffers = true + defer { isAcceptingOffers = false } + let offers = entries.filter { entry in let answerable = entry.allowances.filter(\.isAnswerable) return !answerable.isEmpty && answerable.allSatisfy(\.isOfferFromAllower) } - guard !offers.isEmpty else { return [] } - - isAcceptingOffers = true - defer { isAcceptingOffers = false } - var accepted: [PaykitAllowanceEntry] = [] for offer in offers { guard self.identity == identity, canContinue() else { break } + let ids = offer.allowances.map(\.scopedId) + do { + // An offer accepted without its mark would never be announced, so it waits unanswered for the next refresh. + try await executor.checkedUpdateLocalState(identity: identity) { $0.unannouncedOfferIds.formUnion(ids) } + } catch { + Logger.warn("Failed to mark an allowance offer before accepting it: \(error)", context: "PaykitAllowance") + continue + } do { // A background accept must not clear the progress flag of a proposal the user has in flight. try await accept(offer, showsProgress: false) - accepted.append(offer) } catch { Logger.warn("Failed to accept an allowance offer: \(error)", context: "PaykitAllowance") } } - return self.identity == identity && canAnnounce() ? accepted : [] + guard self.identity == identity, canAnnounce() else { return [] } + return await takeUnannouncedOffers(identity: identity) + } + + private func takeUnannouncedOffers(identity: String) async -> [PaykitAllowanceEntry] { + let stored = await executor.localState(identity: identity).unannouncedOfferIds + guard !stored.isEmpty else { return [] } + // Marks already returned stay stored until a clear saves; each call retries that clear. + let marked = stored.subtracting(announcedOfferIds) + let accepted = entries.filter { entry in + entry.allowances.contains { marked.contains($0.scopedId) && $0.role == .allowee && $0.lifecycleState == .accepted } + } + announcedOfferIds.formUnion(accepted.flatMap(\.allowances).map(\.scopedId).filter(marked.contains)) + // An offer still unanswered keeps its mark; one ended or withdrawn before the accept will never be announced. + let settled = Set(allowances.filter { $0.lifecycleState != .proposed }.map(\.scopedId)) + await executor.updateLocalState(identity: identity) { $0.unannouncedOfferIds.subtract(settled) } + localState.unannouncedOfferIds.subtract(settled) + return accepted } private func respond( @@ -396,6 +431,11 @@ final class PaykitAllowanceManager { static let acceptanceClockTolerance: TimeInterval = 30 + /// An on-chain miner fee falls outside an allowance's limits, so only a Lightning endpoint pays automatically. + static let automaticPaymentEndpointIdentifiers: Set = Set(allowedPaymentEndpointIdentifiers.filter { + $0 == PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue || $0 == PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + }) + static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( PublicPaykitService.MethodId.publishableMethodIds.filter { $0 != .usdtArbitrum }.map(\.rawValue), network: Env.network diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 2323ef787..92b3c4274 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -181,6 +181,8 @@ enum PaykitLightningPaymentProofStatus: Equatable { case succeeded(preimage: String?) case failed case unknown + /// The node has no payment history yet, as before it starts, so it can't say whether it knows the payment. + case unavailable } protocol PaykitLightningPaymentProofLookingUp: Sendable { @@ -189,7 +191,8 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payment = await LightningService.shared.listPayments()?.first(where: { + guard let payments = await LightningService.shared.listPayments() else { return .unavailable } + guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { return .unknown @@ -293,7 +296,8 @@ actor PaykitPaymentProofService { return try PaykitPaymentStateBackup(subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), pendingProofs: proofs.map(PaykitPaymentStateBackup.Proof.init), activeOnchainAttempt: active, acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) - .backupSnapshot()) + .backupSnapshot(), + allowances: PaykitAllowanceKeychainStore().backupSnapshot()) } } @@ -305,6 +309,9 @@ actor PaykitPaymentProofService { try await mutationLock.withLock { try await persist(restored?.1 ?? proofs) } try PaykitSubscriptionStateStore().restoreBackup(state.subscriptions) try PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).restoreBackup(state.acceptedOneTimeRequests ?? [:]) + if let allowances = state.allowances { + PaykitAllowanceKeychainStore().restoreBackup(allowances) + } } func restoreBackup(_ proofs: [PaykitPaymentStateBackup.Proof]) async throws { @@ -1369,7 +1376,7 @@ actor PaykitPaymentProofService { } guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } switch await lightningPaymentLookup.status(paymentHash: paymentHash) { - case .pending, .unknown: + case .pending, .unknown, .unavailable: continue case .failed: await failLightningPayment(paymentHash: paymentHash) diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 589b4c5cf..b4c0e4a27 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -14,6 +14,9 @@ extension PrivatePaykitService { sdkState: exported.state, consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in contactState.consumedPrivatePaymentListVersion + }, + consumedPrivatePaymentListOwners: state.contacts.compactMapValues { contactState in + contactState.consumedPrivatePaymentListVersion == nil ? nil : contactState.consumedPrivatePaymentListOwner } ) let data = try JSONEncoder().encode(backup) @@ -37,6 +40,7 @@ extension PrivatePaykitService { if let decoded { for (publicKey, versions) in decoded.consumedPrivatePaymentListVersions { state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersion = versions + state.contacts[publicKey]?.consumedPrivatePaymentListOwner = decoded.consumedPrivatePaymentListOwners?[publicKey] } } else { await PaykitSdkService.shared.clearState() diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index 590477986..0ceacfa1a 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -10,6 +10,8 @@ extension PrivatePaykitService { struct ContactState: Codable { var cachedResolvedEndpoints: [StoredPaymentEntry] = [] var consumedPrivatePaymentListVersion: UInt64? + /// The automatic payment attempt that consumed the list, so only that attempt gives it back, also after a restore. + var consumedPrivatePaymentListOwner: String? var localInvoice: StoredInvoice? var receivedInvoicePaymentHashes: [String] = [] var hasPublishedPrivatePaymentList = false @@ -54,5 +56,7 @@ extension PrivatePaykitService { struct Backup: Codable { let sdkState: String let consumedPrivatePaymentListVersions: [String: UInt64] + /// Absent from backups made before list owners existed. + var consumedPrivatePaymentListOwners: [String: String]? = nil } } diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 6de3653f8..a60f1ff29 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -257,10 +257,13 @@ extension PrivatePaykitService { ) } + /// `expectedPreviousVersion`, when given, makes the consumption fail unless the list consumed so far is still that version. func consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, - attemptId: UUID + attemptId: UUID, + owner: String? = nil, + expectedPreviousVersion: UInt64?? = nil ) throws { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey @@ -274,9 +277,13 @@ extension PrivatePaykitService { { throw PrivatePaykitError.paymentListAlreadyConsumed } + if let expectedPreviousVersion, expectedPreviousVersion != consumedVersion { + throw PrivatePaykitError.paymentListAlreadyConsumed + } let previousContactState = state.contacts[publicKey] contactState.consumedPrivatePaymentListVersion = paymentListVersion + contactState.consumedPrivatePaymentListOwner = owner contactState.cachedResolvedEndpoints.removeAll() state.contacts[publicKey] = contactState do { @@ -332,6 +339,7 @@ extension PrivatePaykitService { let previousContactState = state.contacts[publicKey] contactState.consumedPrivatePaymentListVersion = consumption.previousPaymentListVersion + contactState.consumedPrivatePaymentListOwner = nil state.contacts[publicKey] = contactState do { try persistStateOrThrow(markWalletBackup: true) @@ -347,14 +355,18 @@ extension PrivatePaykitService { } /// Rolls back a list version a failed automatic payment consumed, which a restart may have left without its - /// in-memory consumption record. A newer consumption is left alone. - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) throws { + /// in-memory consumption record. A list another payment consumed, or one this attempt never consumed, is left alone. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) throws { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } - guard var contactState = state.contacts[publicKey], contactState.consumedPrivatePaymentListVersion == version else { return } + guard var contactState = state.contacts[publicKey], + contactState.consumedPrivatePaymentListVersion == version, + contactState.consumedPrivatePaymentListOwner == owner + else { return } let previousContactState = state.contacts[publicKey] contactState.consumedPrivatePaymentListVersion = previousVersion + contactState.consumedPrivatePaymentListOwner = nil state.contacts[publicKey] = contactState do { try persistStateOrThrow(markWalletBackup: true) @@ -385,6 +397,7 @@ extension PrivatePaykitService { } let previous = state.contacts[key] contact.consumedPrivatePaymentListVersion = version + contact.consumedPrivatePaymentListOwner = nil contact.cachedResolvedEndpoints.removeAll() state.contacts[key] = contact do { @@ -398,7 +411,10 @@ extension PrivatePaykitService { /// The caller must establish an exact receipt that definitely never reached native dispatch. func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64, previousVersion: UInt64? = nil) throws { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } - guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version else { return } + // A list an automatic payment attempt consumed is that attempt's to give back. + guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version, + contact.consumedPrivatePaymentListOwner == nil + else { return } let previous = contact guard previousVersion.map({ $0 < version }) ?? true else { throw PaykitPaymentRequestError.requestUnavailable } contact.consumedPrivatePaymentListVersion = previousVersion diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 404488c90..5341fee19 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -31,6 +31,7 @@ extension PrivatePaykitService { } else { var contactState = ContactState() contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.consumedPrivatePaymentListOwner = state.contacts[key]?.consumedPrivatePaymentListOwner state.contacts[key] = contactState } } diff --git a/Bitkit/Views/Security/PaymentAuthorization.swift b/Bitkit/Views/Security/PaymentAuthorization.swift index 811a5619d..a9ba9b660 100644 --- a/Bitkit/Views/Security/PaymentAuthorization.swift +++ b/Bitkit/Views/Security/PaymentAuthorization.swift @@ -4,10 +4,16 @@ import SwiftUI @MainActor @Observable final class PaymentAuthorization { + /// What the PIN confirms, since a check that sends nothing must not say it sends a payment. + let pinMessage: String fileprivate(set) var isAskingForPin = false fileprivate(set) var biometricErrorMessage: String? @ObservationIgnored private var pinContinuation: CheckedContinuation? + init(pinMessage: String = t("security__pin_send")) { + self.pinMessage = pinMessage + } + func authorize(settings: SettingsViewModel) async -> Bool { guard settings.requirePinForPayments && settings.pinEnabled else { return true } if settings.useBiometrics && BiometricAuth.isAvailable { @@ -52,6 +58,7 @@ private struct PaymentAuthorizationModifier: ViewModifier { } )) { SendPinScreen( + message: authorization.pinMessage, onCancel: { authorization.resolvePin(false) }, onPinVerified: { authorization.resolvePin(true) } ) diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift index e3f370873..20c2922e0 100644 --- a/Bitkit/Views/Subscriptions/AllowancesView.swift +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -381,7 +381,7 @@ struct SetAllowanceView: View { @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances - @State private var authorization = PaymentAuthorization() + @State private var authorization = PaymentAuthorization(pinMessage: t("security__pin_allowance")) let contact: PubkyContact let onBack: () -> Void @@ -606,7 +606,7 @@ struct AllowanceReviewView: View { @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances - @State private var authorization = PaymentAuthorization() + @State private var authorization = PaymentAuthorization(pinMessage: t("security__pin_allowance")) let entry: PaykitAllowanceEntry @@ -696,7 +696,7 @@ struct AllowanceDetailView: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances - @State private var authorization = PaymentAuthorization() + @State private var authorization = PaymentAuthorization(pinMessage: t("security__pin_allowance")) let entry: PaykitAllowanceEntry diff --git a/Bitkit/Views/Wallets/Send/SendPinScreen.swift b/Bitkit/Views/Wallets/Send/SendPinScreen.swift index aa7dca1ba..8726f9ba7 100644 --- a/Bitkit/Views/Wallets/Send/SendPinScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPinScreen.swift @@ -7,6 +7,7 @@ struct SendPinScreen: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel + var message: String = t("security__pin_send") let onCancel: () -> Void let onPinVerified: () -> Void @@ -21,7 +22,7 @@ struct SendPinScreen: View { .padding(.horizontal, 16) VStack(spacing: 0) { - BodyMText(t("security__pin_send")) + BodyMText(message) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 32) diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index a76659030..b0f9f8ba2 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -444,6 +444,30 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(journal.map(\.stage), [.succeeded]) } + func testRecoveryLeavesTheAttemptOfAPaymentInProgressAlone() async throws { + let harness = AllowanceHarness() + let gate = await harness.payer.holdLightning() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(occurrences: [ + Fixtures.occurrence( + requestId: request.paymentRequestId, + attempts: [Fixtures.attemptRecord(id: AllowanceSdkMock.automaticAttemptId, status: .prepared)] + ), + ])) + await harness.executor.activate(identity: Fixtures.identityKey) + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payLightning") } + await harness.executor.recover(identity: Fixtures.identityKey) + let outcomesDuringTheSend = await harness.sdk.recordedOutcomes + gate.open() + _ = await caller.value + + XCTAssertEqual(outcomesDuringTheSend, [], "A node restart during the send must not fail the attempt being sent") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.sent]) + } + // MARK: Cancellation func testLightningPaymentStillStartsWhenItsCallerIsCancelledDuringTheSend() async throws { @@ -505,8 +529,6 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: "attempt-1", outcome: .succeeded)]) let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal XCTAssertEqual(journal.map(\.stage), [.succeeded]) - let paid = await harness.executor.succeededAutomaticPayments(identity: Fixtures.identityKey) - XCTAssertEqual(paid.map(\.attemptId), ["attempt-1"]) let paidEvents = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: AllowanceHarness.paymentHash) } @@ -618,7 +640,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(exceeds, "Only an allowance active now can take a request") } - func testPaymentListReleasedBeforeBroadcastIsNeverReleasedAgain() async throws { + func testPaymentListReleasedBeforeBroadcastIsReleasedAgainOnlyForItsOwner() async throws { let harness = AllowanceHarness() await harness.payer.setLightningError(AllowanceMockError.unsupported) @@ -626,8 +648,12 @@ final class PaykitAllowanceExecutorTests: XCTestCase { let listOutcomes = await harness.payer.paymentListOutcomes XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) - let released = await harness.payer.releasedLists - XCTAssertEqual(released, [], "A second release could clear another payment's consumption of the same version") + let owners = await harness.payer.releaseOwners + XCTAssertEqual( + owners, + [AllowanceSdkMock.automaticAttemptId], + "The failure record releases only a list this attempt still owns, so another payment's consumption is left alone" + ) let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal XCTAssertEqual(journal.map(\.paymentListVersion), [nil]) } @@ -649,6 +675,114 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 7, previousVersion: 6)]) } + func testRecoverySettlesARestoredSubmittedAttemptFromTheNode() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-restored", attempts: [Fixtures.attemptRecord(id: "restored", status: .submitted)]), + ])) + let captured = PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "restored", request: request, stage: .submitted, paymentHash: paidHash), + ]) + try harness.store.seed(PaykitAllowanceStateBackup(captured).restored(), identity: Fixtures.identityKey) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes.map(\.outcome), [.succeeded]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + } + + func testRecoveryLeavesARestoredAttemptOpenUntilTheNodeCanAnswer() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-restored", attempts: [Fixtures.attemptRecord(id: "restored", status: .submitted)]), + ])) + let captured = PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "restored", request: request, stage: .submitted, paymentHash: paidHash), + ]) + try harness.store.seed(PaykitAllowanceStateBackup(captured).restored(), identity: Fixtures.identityKey) + // A restore activates allowances before the node starts, so its payment history is not there yet. + await harness.lookup.setStatuses([paidHash: .unavailable]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomesBeforeStart = await harness.sdk.recordedOutcomes + XCTAssertTrue(outcomesBeforeStart.isEmpty) + let journalBeforeStart = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journalBeforeStart.map(\.stage), [.submitted]) + + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes.map(\.outcome), [.succeeded]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + } + + func testPaymentListIsNotConsumedWhenItsVersionCannotBeJournaled() async { + let harness = AllowanceHarness() + await harness.payer.failSavesOnListRead(harness.store) + + _ = try? await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertFalse(harness.log.entries.contains("consumePaymentList"), "A restart must find every consumed list in the journal") + } + + func testPaymentListIsNotConsumedWhenTheAttemptWasNeverJournaled() async { + let harness = AllowanceHarness() + await harness.sdk.failJournalAfterReservation(harness.store) + + _ = try? await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertFalse(journal.contains { $0.attemptId == AllowanceSdkMock.automaticAttemptId && $0.paymentListVersion != nil }) + XCTAssertFalse( + harness.log.entries.contains("consumePaymentList"), + "Later saves succeed, but recovery would not find this attempt, so its list must not be consumed" + ) + } + + func testAutomaticPaymentConsumesThePaymentListAsItsAttempt() async throws { + let harness = AllowanceHarness() + + _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let owners = await harness.payer.consumeOwners + XCTAssertEqual(owners, [AllowanceSdkMock.automaticAttemptId]) + } + + func testRecoveryRetriesAPaymentListReleaseThatCouldNotBeSaved() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "failed", request: request, stage: .failed, paymentListVersion: 5)]), + identity: Fixtures.identityKey + ) + await harness.payer.setReleaseSucceeds(false) + + await harness.executor.recover(identity: Fixtures.identityKey) + let kept = await harness.executor.localState(identity: Fixtures.identityKey).journal.map(\.paymentListVersion) + await harness.payer.setReleaseSucceeds(true) + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + XCTAssertEqual(kept, [5], "A release that could not be saved keeps the version for the next recovery") + let owners = await harness.payer.releaseOwners + XCTAssertEqual(owners, ["failed", "failed"], "Recovery retries once, as the attempt that consumed the list, then stops") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.paymentListVersion), [nil]) + } + func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -664,6 +798,8 @@ final class PaykitAllowanceExecutorTests: XCTestCase { let released = await harness.payer.releasedLists XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 3, previousVersion: 2)]) + let owners = await harness.payer.releaseOwners + XCTAssertEqual(owners, ["killed"], "Only the attempt that consumed the list gives it back") } func testRecoveryReleasesThePaymentListOfAnAttemptReconciliationFailed() async throws { @@ -715,6 +851,80 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released.filter { $0 == 7 }.count, 1, "The second attempt's list is released once, by the settlement") } + func testSettlementNeverReleasesAListRecoveryReleasedWhileItRecorded() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "a", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 5 + )]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + let gate = await harness.sdk.holdNextOutcome() + + let settlement = Task { await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false) } + await Self.waitUntil { harness.log.entries.contains("recordPaymentOutcome") } + await harness.executor.recover(identity: Fixtures.identityKey) + gate.open() + await settlement.value + + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released, [5], "The list is released once, by recovery") + } + + func testRecoveryNeverReleasesAListASettlementReleasedWhileItLookedUpThePayment() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "a", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 5 + )]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([AllowanceHarness.paymentHash: .failed]) + await harness.executor.activate(identity: Fixtures.identityKey) + let gate = await harness.lookup.holdNextLookup() + + let recovery = Task { await harness.executor.recover(identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("lightningStatus") } + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false) + gate.open() + await recovery.value + + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released, [5], "The list is released once, by the settlement") + } + + func testRecoveryReleasesThePaymentListOfAFailureThatCouldNotBeSaved() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + await harness.payer.failSavesOnNodeRejection(harness.store) + + _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + let releasedWhileSavesFail = await harness.payer.releasedLists + XCTAssertEqual(releasedWhileSavesFail, [], "A failure that can't be saved leaves the release to the recovery that saves it") + + harness.store.restoreSaves() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: AllowanceSdkMock.automaticAttemptId, status: .failed)]), + ])) + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + let released = await harness.payer.releasedLists + XCTAssertEqual(released.count, 1, "Recovery releases the list once, from the journal") + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -998,6 +1208,68 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) } + @MainActor + func testManagerKeepsEveryAutomaticPaymentPastTheJournalLimit() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let paid = try (0 ..< 250).map { index in + try Fixtures.occurrence(requestId: "req-\(index)", attempts: [Fixtures.attemptRecord(id: "paid-\(index)", status: .succeeded)]) + } + let notPaid = try [ + Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + Paykit.PaymentOccurrenceRecord( + key: Paykit.PaymentOccurrenceKey( + request: Paykit.PaymentAccountingScope( + localPublicKey: Fixtures.otherCounterpartyKey, + counterparty: Fixtures.counterpartyKey, + paymentRequestId: "req-other-identity" + ), + billingPeriod: nil + ), + disposition: .automatic, + allowanceId: Fixtures.walletAllowanceId, + associationRevision: 1, + attempts: [Fixtures.attemptRecord(id: "other-identity", status: .succeeded)] + ), + Fixtures.occurrence( + requestId: "req-manual", + attempts: [Fixtures.attemptRecord(id: "manual", mode: .manual, allowanceId: nil, status: .succeeded)], + allowanceId: nil + ), + ] + await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: paid + notPaid)) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertEqual(manager.autoPaidRequestIds.count, 250, "The journal keeps only the newest 200 attempts; the SDK ledger keeps all") + XCTAssertTrue(manager.autoPaidRequestIds.contains(PaykitPaymentRequest.ID(paymentRequestId: "req-0", counterparty: Fixtures.counterpartyKey))) + let entry = try XCTUnwrap(manager.entries.first) + XCTAssertEqual(manager.autoPaidSats(for: entry), 250 * 1000) + } + + @MainActor + func testManagerLeavesABitcoinRequestWithoutALightningEndpointManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + let usdtOnly = try Fixtures.paymentRequest( + conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")]), + acceptedEndpoints: [PublicPaykitService.MethodId.usdtArbitrum.rawValue] + ) + let onchainOnly = try Fixtures.paymentRequest(acceptedEndpoints: [PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue]) + + for request in [usdtOnly, onchainOnly] { + XCTAssertFalse(manager.coversRequest(request), "An allowance pays over Lightning only") + let isHandling = await manager.isAutomaticallyHandling(request) + XCTAssertFalse(isHandling, "The request reaches the Payment Request sheet instead of waiting for a channel") + } + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(acceptedEndpoints: [ + PublicPaykitService.MethodId.usdtArbitrum.rawValue, Fixtures.lightningIdentifier, + ]))) + } + @MainActor func testManagerLeavesRequestsNotPricedInBitcoinManual() async throws { let harness = AllowanceHarness() @@ -1221,6 +1493,105 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(acceptedIds, [Fixtures.walletAllowanceId]) } + @MainActor + func testManagerAnnouncesAnOfferWhoseAcceptWasCutShortOnTheNextRefresh() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + await harness.sdk.setAcceptAllowanceErrorAfterCommit(CancellationError()) + + let cutShort = await manager.acceptOffersFromAllowers() + + XCTAssertTrue(cutShort.isEmpty) + let acceptedIds = await harness.sdk.acceptedAllowanceIds + XCTAssertEqual(acceptedIds, [Fixtures.walletAllowanceId], "The SDK committed the accept before the call stopped") + + await harness.sdk.setAcceptAllowanceErrorAfterCommit(nil) + let relaunched = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await relaunched.activate(identity: Fixtures.identityKey) + let announced = await relaunched.acceptOffersFromAllowers() + let announcedAgain = await relaunched.acceptOffersFromAllowers() + + XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey], "The accepted offer is announced on the next refresh") + XCTAssertTrue(announcedAgain.isEmpty, "and only once") + let acceptedIdsAfter = await harness.sdk.acceptedAllowanceIds + XCTAssertEqual(acceptedIdsAfter, [Fixtures.walletAllowanceId]) + } + + @MainActor + func testManagerAnnouncesAnOfferWhoseMarkCouldNotBeSaved() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + harness.store.failSaves() + + let first = await manager.acceptOffersFromAllowers() + harness.store.restoreSaves() + let second = await manager.acceptOffersFromAllowers() + + XCTAssertEqual((first + second).map(\.counterparty), [Fixtures.counterpartyKey], "An offer accepted for the user is announced") + } + + @MainActor + func testManagerDoesNotAnnounceAnOfferAgainWhenClearingItsMarkFails() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + await harness.sdk.setAcceptAllowanceErrorAfterCommit(CancellationError()) + _ = await manager.acceptOffersFromAllowers() + await harness.sdk.setAcceptAllowanceErrorAfterCommit(nil) + let relaunched = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await relaunched.activate(identity: Fixtures.identityKey) + + harness.store.failSaves() + let announced = await relaunched.acceptOffersFromAllowers() + harness.store.restoreSaves() + let announcedAgain = await relaunched.acceptOffersFromAllowers() + + XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey]) + XCTAssertTrue(announcedAgain.isEmpty, "An offer already announced is not announced again") + let marks = await harness.executor.localState(identity: Fixtures.identityKey).unannouncedOfferIds + XCTAssertTrue(marks.isEmpty, "The next refresh retries the clear that failed") + } + + @MainActor + func testManagerForgetsWhatItAnnouncedWhenDeactivated() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + await harness.sdk.setAcceptAllowanceErrorAfterCommit(CancellationError()) + _ = await manager.acceptOffersFromAllowers() + await harness.sdk.setAcceptAllowanceErrorAfterCommit(nil) + let relaunched = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await relaunched.activate(identity: Fixtures.identityKey) + harness.store.failSaves() + let announced = await relaunched.acceptOffersFromAllowers() + + relaunched.deactivate() + await relaunched.activate(identity: Fixtures.identityKey) + let announcedAfterReactivation = await relaunched.acceptOffersFromAllowers() + harness.store.restoreSaves() + + XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey]) + XCTAssertEqual( + announcedAfterReactivation.map(\.counterparty), + [Fixtures.counterpartyKey], + "A new session announces an offer whose mark is still stored" + ) + } + @MainActor func testAutomaticOfferAcceptanceKeepsAManualProposalWorking() async throws { let harness = AllowanceHarness() @@ -1473,6 +1844,7 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen private let lock = NSLock() private var states: [String: PaykitAllowanceLocalState] = [:] private var savesFail = false + private var failingSaves = 0 func load(identity: String) throws -> PaykitAllowanceLocalState { lock.withLock { states[identity] ?? PaykitAllowanceLocalState() } @@ -1483,6 +1855,10 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen if savesFail { throw AllowanceMockError.unsupported } + if failingSaves > 0 { + failingSaves -= 1 + throw AllowanceMockError.unsupported + } states[identity] = state } } @@ -1491,6 +1867,14 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen lock.withLock { savesFail = true } } + func restoreSaves() { + lock.withLock { savesFail = false } + } + + func failNextSave() { + lock.withLock { failingSaves = 1 } + } + func seed(_ state: PaykitAllowanceLocalState, identity: String) { lock.withLock { states[identity] = state } } @@ -1561,8 +1945,20 @@ private actor AllowanceLightningLookupMock: PaykitLightningPaymentProofLookingUp self.statuses = statuses } + private var lookupGate: AllowanceGate? + + func holdNextLookup() -> AllowanceGate { + let gate = AllowanceGate() + lookupGate = gate + return gate + } + func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { log.append("lightningStatus") + if let gate = lookupGate { + lookupGate = nil + await gate.wait() + } return statuses[paymentHash.lowercased()] ?? .unknown } } @@ -1635,8 +2031,25 @@ private actor AllowancePayerMock: PaykitAllowancePaying { return payment } - func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { + private(set) var consumeOwners: [String] = [] + private var consumeError: Error? + + func setConsumeError(_ error: Error?) { + consumeError = error + } + + func consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + owner: String, + expectedPreviousVersion: UInt64? + ) async throws { called("consumePaymentList") + if let consumeError { + throw consumeError + } + consumeOwners.append(owner) } func resolvePaymentList( @@ -1656,9 +2069,23 @@ private actor AllowancePayerMock: PaykitAllowancePaying { } private(set) var releasedLists: [ReleasedList] = [] + private(set) var releaseOwners: [String] = [] + private var releaseSucceeds = true + + func setReleaseSucceeds(_ succeeds: Bool) { + releaseSucceeds = succeeds + } - func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { - nil + private var storeFailingOnListRead: AllowanceMemoryStore? + + /// Fails journal saves from the read just before the list's version is journaled. + func failSavesOnListRead(_ store: AllowanceMemoryStore) { + storeFailingOnListRead = store + } + + func consumedPaymentListVersion(publicKey: String) async -> UInt64? { + storeFailingOnListRead?.failSaves() + return nil } private var releaseGate: AllowanceGate? @@ -1670,13 +2097,15 @@ private actor AllowancePayerMock: PaykitAllowancePaying { return gate } - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) async -> Bool { called("releaseConsumedPaymentList") releasedLists.append(ReleasedList(publicKey: publicKey, version: version, previousVersion: previousVersion)) + releaseOwners.append(owner) if let gate = releaseGate { releaseGate = nil await gate.wait() } + return releaseSucceeds } func claimForExecution(_ request: PaykitPaymentRequest) async throws { @@ -1730,8 +2159,15 @@ private actor AllowancePayerMock: PaykitAllowancePaying { } } + private var storeFailingOnNodeRejection: AllowanceMemoryStore? + + func failSavesOnNodeRejection(_ store: AllowanceMemoryStore) { + storeFailingOnNodeRejection = store + } + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { called("failLightningPayment") + storeFailingOnNodeRejection?.failSaves() return lightningErrorIsDefinite } @@ -1851,6 +2287,12 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { return record } + private var acceptAllowanceErrorAfterCommit: Error? + + func setAcceptAllowanceErrorAfterCommit(_ error: Error?) { + acceptAllowanceErrorAfterCommit = error + } + func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord { log.append("acceptAllowance") if let acceptAllowanceError { @@ -1862,6 +2304,9 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { } records[index].state = .accepted acceptedAllowanceIds.append(allowanceId) + if let acceptAllowanceErrorAfterCommit { + throw acceptAllowanceErrorAfterCommit + } return records[index] } @@ -1934,12 +2379,21 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { ) } + private var storeFailingJournal: AllowanceMemoryStore? + + /// Fails the next journal save, the one that records the attempt this reservation returns. + func failJournalAfterReservation(_ store: AllowanceMemoryStore) { + storeFailingJournal = store + } + func reserveAutomaticPayment( occurrence: Paykit.PaymentOccurrence, expectedAssociationRevision: UInt64, checks: Paykit.PaymentExecutionChecks ) async throws -> Paykit.PaymentAttemptDecision { log.append("reserveAutomaticPayment") + storeFailingJournal?.failNextSave() + storeFailingJournal = nil reservedAssociationRevisions.append(expectedAssociationRevision) if let automaticReservation { return automaticReservation @@ -1978,9 +2432,21 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(id: attemptId, admittedAt: checks.trustedTime, status: .submitted)) } + private var outcomeGate: AllowanceGate? + + func holdNextOutcome() -> AllowanceGate { + let gate = AllowanceGate() + outcomeGate = gate + return gate + } + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord { log.append("recordPaymentOutcome") recordedOutcomes.append(report) + if let gate = outcomeGate { + outcomeGate = nil + await gate.wait() + } let status: Paykit.PaymentExecutionStatus = switch report.outcome { case .succeeded: .succeeded case .failed: .failed diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 0a6947f63..e63f1ffbc 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -9,6 +9,104 @@ final class PaykitAllowanceTests: XCTestCase { // MARK: Terms and records + func testAllowanceStateBackupKeepsLabelsAndJournalInTheSharedFormat() throws { + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "req-1", counterparty: Fixtures.counterpartyKey) + let state = try PaykitAllowanceLocalState( + groups: [.init( + id: "group-1", + counterparty: Fixtures.counterpartyKey, + limits: PaykitAllowanceLimits( + perPaymentUsd: XCTUnwrap(Decimal(string: "4.99")), + monthlyUsd: 50, + perPaymentSats: 4990, + monthlySats: 50000 + ), + allowanceIds: [Fixtures.walletAllowanceId], + createdAt: Fixtures.now + )], + journal: [.init( + attemptId: "attempt-1", + isAutomatic: true, + requestId: requestId, + allowanceId: Fixtures.walletAllowanceId, + amountSats: 1000, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: String(repeating: "a", count: 64), + onchainAddress: nil, + transactionId: nil, + stage: .sent, + createdAt: Fixtures.now, + paymentListVersion: 7, + previousPaymentListVersion: 6 + )], + presentedProposalIds: ["presented"], + unannouncedOfferIds: ["unannounced"], + notifiedRequestIds: ["notified"], + lastTrustedTime: Fixtures.now + ) + + let data = try JSONEncoder().encode(PaykitAllowanceStateBackup(state)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let group = try XCTUnwrap((json["groups"] as? [[String: Any]])?.first) + let limits = try XCTUnwrap(group["limits"] as? [String: Any]) + XCTAssertEqual(limits["perPaymentUsd"] as? String, "4.99") + XCTAssertEqual(limits["monthlySats"] as? Int, 50000) + XCTAssertEqual(group["createdAt"] as? String, "2026-09-24T12:00:00.000Z") + let entry = try XCTUnwrap((json["journal"] as? [[String: Any]])?.first) + XCTAssertEqual(entry["stage"] as? String, "sent") + XCTAssertEqual(entry["paymentListVersion"] as? Int, 7) + XCTAssertEqual((entry["requestId"] as? [String: Any])?["paymentRequestId"] as? String, "req-1") + XCTAssertEqual(json["lastTrustedTime"] as? String, "2026-09-24T12:00:00.000Z") + XCTAssertTrue(entry["onchainAddress"] is NSNull, "Android requires every key, so an absent value is written as null") + XCTAssertTrue(entry["transactionId"] is NSNull) + + let restored = try JSONDecoder().decode(PaykitAllowanceStateBackup.self, from: data).restored() + XCTAssertEqual(restored, state) + } + + func testAManualPaymentJournalEntryWritesItsMissingAllowanceAsNull() throws { + var state = PaykitAllowanceLocalState() + state.journal = [.init( + attemptId: "manual-1", + isAutomatic: false, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "req-1", counterparty: Fixtures.counterpartyKey), + allowanceId: nil, + amountSats: 1000, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: nil, + onchainAddress: nil, + transactionId: nil, + stage: .submitted, + createdAt: Fixtures.now + )] + + let data = try JSONEncoder().encode(PaykitAllowanceStateBackup(state)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let entry = try XCTUnwrap((json["journal"] as? [[String: Any]])?.first) + + for key in ["allowanceId", "paymentHash", "onchainAddress", "transactionId", "paymentListVersion", "previousPaymentListVersion"] { + XCTAssertTrue(entry[key] is NSNull, "\(key) is written as null for Android") + } + XCTAssertTrue(json["lastTrustedTime"] is NSNull) + } + + func testAnUnreadableAllowanceStateLeavesTheRestOfTheBackupReadable() throws { + let json = Data(#""" + { + "subscriptions": {}, + "pendingProofs": [], + "allowances": { + "pubky-good": {"groups": [], "journal": [], "presentedProposalIds": [], "unannouncedOfferIds": [], "notifiedRequestIds": []}, + "pubky-bad": {"groups": [], "journal": [{"stage": "teleported"}]} + } + } + """#.utf8) + + let backup = try JSONDecoder().decode(PaykitPaymentStateBackup.self, from: json) + + XCTAssertEqual(backup.allowances.map { Array($0.byIdentity.keys) }, ["pubky-good"]) + } + func testTermsCarryPerPaymentRangeAnchoredUtcMonthAndAllowlist() throws { let monthAnchor = PaykitAllowanceTime.monthStart(containing: Fixtures.now) let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] @@ -603,7 +701,8 @@ enum PaykitAllowanceFixtures { amount: String = "0.00001", asset: String = PaykitIssuerInterop.bitcoinAsset, conversion: Paykit.PaymentConversion? = nil, - createdAt: String = "2026-09-24T11:00:00Z" + createdAt: String = "2026-09-24T11:00:00Z", + acceptedEndpoints: [String] = [lightningIdentifier] ) throws -> PaykitPaymentRequest { let record = try Paykit.PaymentRequestRecord( counterparty: counterparty, @@ -622,7 +721,7 @@ enum PaykitAllowanceFixtures { paymentReference: Paykit.PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, recurrence: nil, - acceptedPaymentEndpointIdentifiers: [lightningIdentifier], + acceptedPaymentEndpointIdentifiers: acceptedEndpoints, paymentEndpoints: nil, requiredAppId: "bitkit", conversion: conversion, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2fb81a4bc..7067b5ed2 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1146,6 +1146,74 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testReleaseGivesBackOnlyAListItsOwnAttemptConsumed() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 6), attemptId: UUID()) + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 7), attemptId: UUID(), owner: "attempt-a") + + try await service.releaseConsumedPaymentList(publicKey: publicKey, version: 7, previousVersion: 6, owner: "attempt-b") + let afterOtherOwner = await service.state.contacts[publicKey] + try await service.releaseConsumedPaymentList(publicKey: publicKey, version: 7, previousVersion: 6, owner: "attempt-a") + let afterOwner = await service.state.contacts[publicKey] + + XCTAssertEqual(afterOtherOwner?.consumedPrivatePaymentListVersion, 7) + XCTAssertEqual(afterOwner?.consumedPrivatePaymentListVersion, 6) + XCTAssertNil(afterOwner?.consumedPrivatePaymentListOwner) + } + + func testReleaseLeavesAManualPaymentsConsumptionOfTheSameVersion() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 7), attemptId: UUID()) + + // An automatic attempt journaled version 7 but a restart came before it consumed the list. + try await service.releaseConsumedPaymentList(publicKey: publicKey, version: 7, previousVersion: nil, owner: "attempt-a") + + let contact = await service.state.contacts[publicKey] + XCTAssertEqual(contact?.consumedPrivatePaymentListVersion, 7) + } + + func testConsumeFailsWhenTheListChangedSinceItsPreviousVersionWasRead() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 6), attemptId: UUID()) + + do { + try await service.consumePrivatePaymentList( + publicKey: publicKey, + context: listContext(version: 7), + attemptId: UUID(), + owner: "attempt-a", + expectedPreviousVersion: .some(nil) + ) + XCTFail("A list consumed since the attempt read it must not be consumed over") + } catch PrivatePaykitError.paymentListAlreadyConsumed {} + + let contact = await service.state.contacts[publicKey] + XCTAssertEqual(contact?.consumedPrivatePaymentListVersion, 6) + XCTAssertNil(contact?.consumedPrivatePaymentListOwner) + } + + func testContactStateSavedBeforeListOwnersDecodes() throws { + let saved = #"{"cachedResolvedEndpoints":[],"consumedPrivatePaymentListVersion":3,"receivedInvoicePaymentHashes":[],"hasPublishedPrivatePaymentList":false}"# + + let contact = try JSONDecoder().decode(PrivatePaykitService.ContactState.self, from: Data(saved.utf8)) + + XCTAssertEqual(contact.consumedPrivatePaymentListVersion, 3) + XCTAssertNil(contact.consumedPrivatePaymentListOwner) + } + + private func listContext(version: UInt64) -> PrivatePaykitPaymentContext { + PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: version) + } + func testUsdtOnlyPrivateContactRetainsItsEndpointAndListVersion() async throws { snapshotAppDefaultsDomain() UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 3740ad349..8682466a8 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1749,7 +1749,8 @@ final class PrivatePaykitServiceTests: XCTestCase { let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let backup = PrivatePaykitService.Backup( sdkState: "opaque recovery state", - consumedPrivatePaymentListVersions: [publicKey: 7] + consumedPrivatePaymentListVersions: [publicKey: 7], + consumedPrivatePaymentListOwners: [publicKey: "attempt-a"] ) let encoded = try String(decoding: JSONEncoder().encode(backup), as: UTF8.self) let service = PrivatePaykitService() @@ -1759,10 +1760,20 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(try Keychain.loadString(key: .paykitRecoveryBackup), backup.sdkState) let state = await service.testContactState(publicKey: publicKey) XCTAssertEqual(state?.consumedPrivatePaymentListVersion, 7) + XCTAssertEqual(state?.consumedPrivatePaymentListOwner, "attempt-a", "A restored failed attempt can still give its list back") XCTAssertNil(try Keychain.load(key: .paykitSession)) XCTAssertNil(try Keychain.load(key: .pubkySecretKey)) } + func testBackupMadeBeforeListOwnersDecodes() throws { + let saved = #"{"sdkState":"opaque recovery state","consumedPrivatePaymentListVersions":{"pubkycontact":7}}"# + + let backup = try JSONDecoder().decode(PrivatePaykitService.Backup.self, from: Data(saved.utf8)) + + XCTAssertEqual(backup.consumedPrivatePaymentListVersions, ["pubkycontact": 7]) + XCTAssertNil(backup.consumedPrivatePaymentListOwners) + } + func testReservationStoreBacksUpRestoredCeiling() async throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 91465c895..d1e8c84ba 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import Combine import enum Paykit.PaykitError import class Paykit.PubkySessionAccess import struct Paykit.PubkySessionBootstrapResult @@ -279,6 +280,29 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testAutomaticPaykitSessionAppearsWhenTheSessionChangeEndsAndIsAnnounced() async throws { + try await withEmptyIdentityStorage { + let secret = String(repeating: "01", count: 32) + let manager = PubkyProfileManager() + manager.publicKey = try PubkyProfileManager.publicKeyFromSecretKey(secret) + manager.authState = .authenticated + var sessionsWhenSettled: [PubkyProfileManager.SignedInSession?] = [] + let subscription = PubkyProfileManager.sessionSettledPublisher.sink { sessionsWhenSettled.append(manager.automaticPaykitSession) } + defer { subscription.cancel() } + var sessionDuringChange: PubkyProfileManager.SignedInSession? + + _ = try? await manager.adoptRingIdentity(pubky: XCTUnwrap(manager.publicKey), loadSecret: { _, _ in + sessionDuringChange = manager.automaticPaykitSession + return nil + }) + + XCTAssertNil(sessionDuringChange, "No automatic session while a session change is in flight, though authState reads authenticated") + XCTAssertEqual(sessionsWhenSettled.count, 1) + XCTAssertNotNil(sessionsWhenSettled.first ?? nil, "The session is readable when the change is announced as settled") + } + } + @MainActor func testDeletionSessionRejectsAReplacementAndANewSessionForTheSameKey() async throws { try await withEmptyIdentityStorage { diff --git a/changelog.d/next/1141.fixed.md b/changelog.d/next/1141.fixed.md new file mode 100644 index 000000000..dc01fea0d --- /dev/null +++ b/changelog.d/next/1141.fixed.md @@ -0,0 +1 @@ +Allowances now load their contacts right after sign-in, announce every allowance accepted automatically, keep their limits and Auto-paid totals on busy wallets and across a wallet restore, and leave requests they can't pay over Lightning for manual payment. diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 534aa5a3a..248b0bcb0 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -6,6 +6,7 @@ limit falls back to the normal Payment Request sheet, and either side ends it. T pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. The PIN journey checks that the payer's grant, accept and end ask for the payment PIN when the user requires one, while automatic payments never prompt. +The restore journey checks that a payer restored from its recovery phrase keeps its allowance history. ## Setup @@ -23,7 +24,10 @@ UI snapshot cannot see. The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests -created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +created from the Payments tab of the payee, priced in bitcoin. On Android, enter the amount in sats: +a request entered in the fiat unit is priced in USD, and allowances pay only bitcoin-priced requests, so it +stays manual. iOS converts a dollar entry to a bitcoin amount when it creates the request, so the fiat unit +works there. Dollar amounts on screen are converted at the current rate, so a sats amount next to them will differ between runs. ## Reference evidence diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml index f3003d79f..e918600bf 100644 --- a/journeys/allowances/end-stops-auto-pay.xml +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -16,10 +16,10 @@ On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (id "PaymentRequestsBell") as an ordinary Payment Request Dismiss that request On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" - Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + Set a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml; the payee's wallet accepts it by itself On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end Verify the payee's row reads "Ended" - On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" + On the payer instance, verify its row for that allowance reads "Ended", with no amount since nothing was paid automatically On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml index 1b32ebdd3..557481000 100644 --- a/journeys/allowances/monthly-cap-reached.xml +++ b/journeys/allowances/monthly-cap-reached.xml @@ -8,7 +8,7 @@ Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted - On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"), and have the payee accept it + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"); the payee's wallet accepts it by itself Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml index d49428b1c..bb96fc1bb 100644 --- a/journeys/allowances/pin-gates-allowance-changes.xml +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -16,11 +16,12 @@ On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (id "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month Tap Save Allowance (id "AllowanceSave") - Verify the PIN pad shows over the sheet, then press back + Verify the PIN pad shows over the sheet and reads "Please enter your PIN code to confirm this allowance change.", then press back Verify the Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no "Allowance Added" notification Tap Save Allowance again and enter the PIN Verify the sheet dismisses and the payer's row reads "Waiting for an answer" - On the payee instance, verify "Allowance Added" arrives with no review sheet (id "AllowanceReview"), no tap and no PIN, and the payer's row turns Active + On the payee instance, verify "Allowance Added" arrives with no review sheet (id "AllowanceReview"), no tap and no PIN + On the payer instance, verify the payee's row turns Active On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end Verify the PIN pad shows, press back, and verify the detail sheet (id "AllowanceDetail") is back with the swipe control at its start and the allowance still Active diff --git a/journeys/allowances/restore-keeps-history.xml b/journeys/allowances/restore-keeps-history.xml new file mode 100644 index 000000000..b6a447eea --- /dev/null +++ b/journeys/allowances/restore-keeps-history.xml @@ -0,0 +1,25 @@ + + + A payer restored from its recovery phrase keeps its allowance history: the allowances list, the + "Paid automatically" total and the "Auto-paid" tag come back from the wallet backup. Reset And + Restore wipes the keychain, where the allowance state lives, so a restore on the same instance + reads it back from the backup; no second install is needed. Requires the Active allowance from + set-and-accept.xml and the payer's notification permission granted. The journey wipes the payer + half way through, so do not run it against a wallet whose funds matter. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payer instance, open the menu, navigate to Settings, then the "Security" tab (id "Tab-security"), then tap "Back Up Your Money" (id "BackupWallet"), continue past the intro (id "BackupIntroViewContinue"), tap to reveal the phrase (id "TapToReveal") and record the phrase from the word grid (id "SeedContainer") + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (id "Tab-payments") and send the payer a one-time Payment Request for $2 with the note "Restore" + Switch to the payer instance, with Bitkit in the foreground, and verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted as a banner, or under Bitkit in Notification Center + On the payer instance, open the drawer menu, tap Subscriptions and tap the Allowances tab (id "Tab-allowances"), then tap the payee's row (id "AllowanceRow-<entryId>") and record PAID AUTOMATICALLY (id "AllowancePaidSoFar") + Open Settings, then the "Security" tab, then "Data Backups" (id "BackupSettings"), and wait on the list (id "BackupScrollView") until the "Boosts & Transfers" row reports a backup made after the payment — that row carries no id, so read its text + Open Settings, then the "Security" tab, then "Reset And Restore" (id "ResetAndRestore"), tap "Reset Wallet" and confirm with "Yes, Reset" in the alert — neither carries an id, so tap them by label + On the onboarding screen tap "Restore" (id "RestoreWallet"), then "MultipleDevices-button", enter the recorded words into the word inputs (id "Word-0" onwards) and tap Restore (id "RestoreButton") + Wait for the restore to finish and tap "Get Started" (id "GetStartedButton") + Open the drawer menu, tap Subscriptions and tap the Allowances tab + Verify the payee's row (id "AllowanceRow-<entryId>") is listed as Active + Tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") shows the total recorded before the reset, converted at the current rate + Close the sheet, tap the Payments tab and verify the "Restore" row (id "PaymentRequestRow-<paymentRequestId>-one-time") is listed with a subtitle ending in "· Auto-paid" + +