diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 5531e8f5c..3ae91a344 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -199,6 +199,7 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher {
sheets: SheetViewModel,
canRetryPreparation: Bool,
handleSubscriptionNotification: () async -> Void,
+ allowanceProposal: () -> PaykitAllowanceEntry? = { nil },
presentPaymentRequest: () async -> Void
) async {
guard sheets.activeSheetConfiguration == nil || canRetryPreparation,
@@ -219,6 +220,10 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher {
sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription)))
return
}
+ if let allowance = allowanceProposal() {
+ sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceReview(allowance)))
+ return
+ }
await presentPaymentRequest()
}
@@ -359,6 +364,7 @@ struct AppScene: View {
@State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache(
activityChanges: CoreService.shared.activity.activitiesChangedPublisher
)
+ @State private var paykitAllowanceManager = PaykitAllowanceManager()
@State private var hideSplash = false
@State private var removeSplash = false
@@ -594,6 +600,7 @@ struct AppScene: View {
.environment(wallet.usdtWallet)
.environment(calculatorInputManager)
.environment(paykitPaymentRequestManager)
+ .environment(paykitAllowanceManager)
}
private var paykitEventContent: some View {
@@ -606,6 +613,7 @@ struct AppScene: View {
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)
@@ -624,6 +632,7 @@ struct AppScene: View {
} else if authState == .idle {
contactsManager.reset()
paykitPaymentRequestManager.clear()
+ paykitAllowanceManager.deactivate()
}
}
.onReceive(contactsManager.savedContactsChangedPublisher) { contacts in
@@ -664,6 +673,9 @@ struct AppScene: View {
guard !Task.isCancelled else { return }
paykitProofRefresh.complete(request)
}
+ .onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in
+ handlePaykitAllowanceEvent(event)
+ }
.onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in
Task { @MainActor in
app.retainPaykitOnchainPaymentResolution(resolution, identity: pubkyProfile.publicKey)
@@ -701,6 +713,12 @@ struct AppScene: View {
Task { await presentNextIncomingPaykitItem() }
}
}
+ .onChange(of: paykitAllowanceManager.isUpdating) { _, isUpdating in
+ // A request held back while allowances were updating is presented as soon as they settle.
+ if !isUpdating {
+ Task { await presentNextIncomingPaykitItem() }
+ }
+ }
.onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in
handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current)
}
@@ -1407,6 +1425,19 @@ struct AppScene: View {
let refreshed = await paykitPaymentRequestManager.refresh(
mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh
)
+ // Deleting the profile withdraws the payment lists an automatic payment would consume.
+ if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile {
+ await paykitAllowanceManager.refresh()
+ // A request whose manual sheet is already open stays with the user.
+ let manualRequestId = incomingPaymentRequestPreparation.flatMap { $0.ownsSheet(sheets) ? $0.request?.id : nil }
+ let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(
+ paykitPaymentRequestManager.pendingRequests.filter { $0.id != manualRequestId }
+ )
+ let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds()
+ if handledAutomatically || adoptedAcceptances, canRefresh() {
+ await paykitPaymentRequestManager.refresh(mode: .stored, canRefresh: canRefresh)
+ }
+ }
guard pubkyProfile.authState == .authenticated,
PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey)
else { return false }
@@ -1566,6 +1597,20 @@ struct AppScene: View {
else { return }
for request in requests {
guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return }
+ if await paykitAllowanceManager.isAutomaticallyHandling(request) {
+ // The next refresh presents it if the allowance flow hands it back; presenting again now would spin on it.
+ if paykitPaymentRequestManager.requestedPresentationId == request.id {
+ app.toast(type: .info, title: t("subscriptions__allowance_payment_in_progress"))
+ paykitPaymentRequestManager.releaseRequestedPresentation(request)
+ }
+ shouldPresentNextRequest = false
+ continue
+ }
+ // A refresh or pass still running may hand this request to the allowance flow; a later round presents it.
+ if paykitAllowanceManager.isUpdating, paykitAllowanceManager.mayCover(request) {
+ shouldPresentNextRequest = false
+ continue
+ }
let preparation: IncomingPaykitPaymentRequestPreparation
if let current = incomingPaymentRequestPreparation, current.ownsSheet(sheets) {
guard current.request?.id == request.id else { continue }
@@ -1898,6 +1943,44 @@ struct AppScene: View {
await presentNextIncomingPaykitPaymentRequest()
}
+ private func handlePaykitAllowanceEvent(_ event: PaykitAllowanceEvent) {
+ switch event {
+ case let .paidAutomatically(counterparty, amountSats, paymentId):
+ let title = t("subscriptions__allowance_executed_title")
+ let description = t(
+ "subscriptions__allowance_executed_description",
+ variables: ["amount": AllowanceAmountText.fiat(sats: amountSats, currency: currency), "name": contactName(counterparty)]
+ )
+ PaykitAllowanceNotifier.post(title: title, body: description, fallback: {
+ app.toast(type: .lightning, title: title, description: description, accessibilityIdentifier: "AllowancePaidToast")
+ })
+ Task {
+ await paykitAllowanceManager.refresh()
+ do {
+ try await activity.setContact(counterparty, forPaymentId: paymentId)
+ } catch {
+ Logger.warn("Failed to set contact for an automatic allowance payment: \(error)", context: "AppScene")
+ }
+ }
+ case let .limitReached(counterparty, amountSats):
+ let title = t("subscriptions__allowance_limit_title")
+ let description = t(
+ "subscriptions__allowance_limit_description",
+ variables: ["amount": AllowanceAmountText.fiat(sats: amountSats, currency: currency), "name": contactName(counterparty)]
+ )
+ PaykitAllowanceNotifier.post(title: title, body: description, fallback: {
+ app.toast(type: .warning, title: title, description: description, accessibilityIdentifier: "AllowanceLimitToast")
+ })
+ case .ledgerChanged:
+ Task { await paykitAllowanceManager.refresh() }
+ }
+ }
+
+ private func contactName(_ publicKey: String) -> String {
+ contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName
+ ?? PubkyPublicKeyFormat.displayTruncated(publicKey)
+ }
+
private func presentNextIncomingPaykitItem() async {
guard scenePhase == .active,
pubkyProfile.automaticPaykitSession != nil,
@@ -1908,6 +1991,7 @@ struct AppScene: View {
sheets: sheets,
canRetryPreparation: canRetryIncomingPaymentRequestPreparation,
handleSubscriptionNotification: handlePendingPaykitSubscriptionNotification,
+ allowanceProposal: paykitAllowanceManager.proposalForPresentation,
presentPaymentRequest: presentNextIncomingPaykitPaymentRequest
)
}
diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift
index 22807679c..48763f950 100644
--- a/Bitkit/Models/PaykitPaymentStateBackup.swift
+++ b/Bitkit/Models/PaykitPaymentStateBackup.swift
@@ -77,6 +77,7 @@ struct PaykitPaymentStateBackup: Codable {
let proofData: String?
let conversionQuoteId: String?
let billingPeriod: Period?
+ let allowanceId: String?
let onchainAddress: String?
let onchainAmountSats: UInt64?
let onchainWalletId: String?
@@ -101,6 +102,7 @@ struct PaykitPaymentStateBackup: Codable {
proofData = proof.proofData
conversionQuoteId = proof.conversionQuoteId
billingPeriod = proof.billingPeriod.map { Period(startsAt: $0.sdkValue.startsAt, endsAt: $0.sdkValue.endsAt) }
+ allowanceId = proof.allowanceId
onchainAddress = proof.onchainAddress
onchainAmountSats = proof.onchainAmountSats
onchainWalletId = proof.onchainWalletId
@@ -129,6 +131,7 @@ struct PaykitPaymentStateBackup: Codable {
paymentEndpointIdentifier: paymentEndpointIdentifier,
kind: kind,
billingPeriod: period,
+ allowanceId: allowanceId,
paymentStarted: paymentStarted,
paymentIdentifier: paymentIdentifier,
proofData: proofData,
diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
index 0bcf440cd..3c090e3fa 100644
--- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
@@ -1580,3 +1580,50 @@
"wallet__receive_liquidity__label_additional" = "سيولة رصيد إنفاق إضافية";
"wallet__receive_cjit_error_max__title" = "الحد الأقصى لسعة الاستلام";
"wallet__receive_cjit_error_max__description" = "الحد الأقصى الذي يمكنك استلامه في رصيد الإنفاق الآن هو ₿ {amount}.";
+"subscriptions__allowances" = "المخصصات";
+"subscriptions__allowances_empty_headline" = "أعدّ\nالمخصصات";
+"subscriptions__allowances_empty_description" = "يمكنك تعيين حدود إنفاق لتلبية طلبات الدفع من جهات الاتصال الموثوقة تلقائيًا.";
+"subscriptions__allowance_add" = "إضافة مخصص";
+"subscriptions__allowance_title" = "المخصص";
+"subscriptions__allowance_choose_contact" = "اختر جهة اتصال";
+"subscriptions__allowance_no_contacts" = "أضف جهة اتصال أولًا. تغطي المخصصات طلبات الدفع من جهات اتصالك.";
+"subscriptions__allowance_set_title" = "تعيين المخصص";
+"subscriptions__allowance_set_explanation" = "وافق تلقائيًا على طلبات الدفع من {name} ضمن هذه الحدود:";
+"subscriptions__allowance_payment_limit" = "حد الدفعة";
+"subscriptions__allowance_monthly_allowance" = "المخصص الشهري";
+"subscriptions__allowance_set_summary" = "ستُدفع الطلبات حتى {perPayment} تلقائيًا، بحد أقصى {monthly} شهريًا، دون سؤالك في كل مرة.";
+"subscriptions__allowance_save" = "حفظ المخصص";
+"subscriptions__allowance_monthly_limit" = "الحد الشهري";
+"subscriptions__allowance_per_payment_short" = "{amount} للدفعة";
+"subscriptions__allowance_per_payment" = "لكل دفعة";
+"subscriptions__allowance_each_month" = "كل شهر";
+"subscriptions__allowance_up_to" = "حتى {amount}";
+"subscriptions__allowance_status_active" = "نشط";
+"subscriptions__allowance_status_waiting" = "بانتظار الرد";
+"subscriptions__allowance_status_needs_answer" = "بانتظار ردك";
+"subscriptions__allowance_status_scheduled" = "غير نشط بعد";
+"subscriptions__allowance_status_expired" = "منتهي الصلاحية";
+"subscriptions__allowance_status_declined" = "مرفوض";
+"subscriptions__allowance_status_conflicted" = "يحتاج إلى انتباه";
+"subscriptions__allowance_status_ended" = "منتهٍ";
+"subscriptions__allowance_paid_automatically" = "تم دفع {amount} تلقائيًا";
+"subscriptions__allowance_paid_so_far" = "مدفوع تلقائيًا";
+"subscriptions__allowance_offer_headline" = "{name} يعرض\nمخصصًا";
+"subscriptions__allowance_request_headline" = "{name} يطلب\nمخصصًا";
+"subscriptions__allowance_offer_explanation" = "ستدفع محفظة {name} طلباتك ضمن هذه الحدود دون السؤال في كل مرة. يمكن لأي منكما إنهاؤه.";
+"subscriptions__allowance_request_explanation" = "ستدفع محفظتك طلبات {name} ضمن هذه الحدود دون سؤالك في كل مرة. يمكن لأي منكما إنهاؤه.";
+"subscriptions__allowance_accept" = "قبول";
+"subscriptions__allowance_decline" = "رفض";
+"subscriptions__allowance_swipe_end" = "اسحب لإنهاء المخصص";
+"subscriptions__allowance_swipe_withdraw" = "اسحب للتراجع";
+"subscriptions__allowance_detail_active_allower" = "تُدفع الطلبات ضمن هذه الحدود تلقائيًا. أي طلب يتجاوزها يحتاج إلى موافقتك أولًا.";
+"subscriptions__allowance_detail_active_allowee" = "تُدفع طلباتك ضمن هذه الحدود تلقائيًا.";
+"subscriptions__allowance_detail_ended" = "انتهى هذا المخصص. سيتطلب كل طلب موافقتك من جديد.";
+"subscriptions__allowance_error_not_linked" = "جهة الاتصال هذه غير متصلة بعد. حاول مرة أخرى بعد قليل.";
+"subscriptions__allowance_error_unavailable" = "هذا المخصص غير متاح حاليًا.";
+"subscriptions__allowance_payment_in_progress" = "يجري دفع هذا الطلب بالفعل.";
+"subscriptions__allowance_executed_title" = "تم تنفيذ الدفع";
+"subscriptions__allowance_executed_description" = "أرسل الدفع التلقائي {amount} إلى {name}";
+"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 640207c62..c0509d9f8 100644
--- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
@@ -1764,3 +1764,50 @@
"usdt__deposit_tron_refund_note" = "Les devolucions de TRON poden requerir l'assistència d'un operador d'Orchestra.";
"usdt__deposit_refund_address_network" = "Adreça USDT a {network}";
"wallet__send_error_support" = "Contacta amb el suport";
+"subscriptions__allowances" = "Assignacions";
+"subscriptions__allowances_empty_headline" = "Configura\nassignacions";
+"subscriptions__allowances_empty_description" = "Pots establir límits de despesa per pagar automàticament les sol·licituds de pagament de contactes de confiança.";
+"subscriptions__allowance_add" = "Afegir assignació";
+"subscriptions__allowance_title" = "Assignació";
+"subscriptions__allowance_choose_contact" = "Triar contacte";
+"subscriptions__allowance_no_contacts" = "Afegeix primer un contacte. Les assignacions cobreixen les sol·licituds de pagament dels teus contactes.";
+"subscriptions__allowance_set_title" = "Establir assignació";
+"subscriptions__allowance_set_explanation" = "Aprova automàticament les sol·licituds de pagament de {name} per sota d'aquests límits:";
+"subscriptions__allowance_payment_limit" = "Límit per pagament";
+"subscriptions__allowance_monthly_allowance" = "Assignació mensual";
+"subscriptions__allowance_set_summary" = "Les sol·licituds de fins a {perPayment} es pagaran automàticament, fins a {monthly} al mes, sense preguntar-te cada vegada.";
+"subscriptions__allowance_save" = "Desar assignació";
+"subscriptions__allowance_monthly_limit" = "Límit mensual";
+"subscriptions__allowance_per_payment_short" = "{amount} per pagament";
+"subscriptions__allowance_per_payment" = "Per pagament";
+"subscriptions__allowance_each_month" = "Cada mes";
+"subscriptions__allowance_up_to" = "Fins a {amount}";
+"subscriptions__allowance_status_active" = "Activa";
+"subscriptions__allowance_status_waiting" = "Esperant resposta";
+"subscriptions__allowance_status_needs_answer" = "Esperant la teva resposta";
+"subscriptions__allowance_status_scheduled" = "Encara no activa";
+"subscriptions__allowance_status_expired" = "Caducada";
+"subscriptions__allowance_status_declined" = "Rebutjada";
+"subscriptions__allowance_status_conflicted" = "Requereix atenció";
+"subscriptions__allowance_status_ended" = "Finalitzada";
+"subscriptions__allowance_paid_automatically" = "{amount} pagat automàticament";
+"subscriptions__allowance_paid_so_far" = "Pagat automàticament";
+"subscriptions__allowance_offer_headline" = "{name} t'ofereix\nuna assignació";
+"subscriptions__allowance_request_headline" = "{name} demana\nuna assignació";
+"subscriptions__allowance_offer_explanation" = "La cartera de {name} pagarà les teves sol·licituds dins d'aquests límits sense preguntar cada vegada. Qualsevol de vosaltres hi pot posar fi.";
+"subscriptions__allowance_request_explanation" = "La teva cartera pagarà les sol·licituds de {name} dins d'aquests límits sense preguntar-te cada vegada. Qualsevol de vosaltres hi pot posar fi.";
+"subscriptions__allowance_accept" = "Acceptar";
+"subscriptions__allowance_decline" = "Rebutjar";
+"subscriptions__allowance_swipe_end" = "Desplaça per finalitzar l'assignació";
+"subscriptions__allowance_swipe_withdraw" = "Desplaça per retirar";
+"subscriptions__allowance_detail_active_allower" = "Les sol·licituds dins d'aquests límits es paguen automàticament. Qualsevol import superior et demanarà confirmació abans.";
+"subscriptions__allowance_detail_active_allowee" = "Les teves sol·licituds dins d'aquests límits es paguen automàticament.";
+"subscriptions__allowance_detail_ended" = "Aquesta assignació ha finalitzat. Cada sol·licitud tornarà a demanar confirmació.";
+"subscriptions__allowance_error_not_linked" = "Aquest contacte encara no està connectat. Torna-ho a provar d'aquí a un moment.";
+"subscriptions__allowance_error_unavailable" = "Aquesta assignació no està disponible ara mateix.";
+"subscriptions__allowance_payment_in_progress" = "Aquesta sol·licitud ja s'està pagant.";
+"subscriptions__allowance_executed_title" = "Pagament executat";
+"subscriptions__allowance_executed_description" = "El pagament automàtic ha enviat {amount} a {name}";
+"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 b28cb1392..6dd33eeb9 100644
--- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
@@ -1903,3 +1903,50 @@
"usdt__deposit_refund_confirmation" = "Požádat o vrácení vkladu {amount} USDT?\n\nSíť:\n{network}\n\nAdresa pro vrácení:\n{address}\n\nPotvrďte, že tato adresa může přijímat USDT v síti {shortNetwork}. Mohou být účtovány poplatky za vrácení.";
"usdt__deposit_tron_refund_note" = "Vrácení prostředků v síti TRON může vyžadovat pomoc operátora Orchestra.";
"usdt__deposit_refund_address_network" = "Adresa USDT v síti {network}";
+"subscriptions__allowances" = "Povolení plateb";
+"subscriptions__allowances_empty_headline" = "Nastavte\npovolení plateb";
+"subscriptions__allowances_empty_description" = "Můžete nastavit limity útraty a automaticky tak vyřizovat žádosti o platbu od důvěryhodných kontaktů.";
+"subscriptions__allowance_add" = "Přidat povolení";
+"subscriptions__allowance_title" = "Povolení plateb";
+"subscriptions__allowance_choose_contact" = "Vybrat kontakt";
+"subscriptions__allowance_no_contacts" = "Nejprve přidejte kontakt. Povolení plateb se vztahují na žádosti o platbu od vašich kontaktů.";
+"subscriptions__allowance_set_title" = "Nastavit povolení";
+"subscriptions__allowance_set_explanation" = "Automaticky schvalovat žádosti o platbu od {name} do těchto limitů:";
+"subscriptions__allowance_payment_limit" = "Limit platby";
+"subscriptions__allowance_monthly_allowance" = "Měsíční povolená částka";
+"subscriptions__allowance_set_summary" = "Žádosti do {perPayment} budou placeny automaticky, maximálně {monthly} měsíčně, aniž bychom se vás pokaždé ptali.";
+"subscriptions__allowance_save" = "Uložit povolení";
+"subscriptions__allowance_monthly_limit" = "Měsíční limit";
+"subscriptions__allowance_per_payment_short" = "{amount} za platbu";
+"subscriptions__allowance_per_payment" = "Za platbu";
+"subscriptions__allowance_each_month" = "Každý měsíc";
+"subscriptions__allowance_up_to" = "Až {amount}";
+"subscriptions__allowance_status_active" = "Aktivní";
+"subscriptions__allowance_status_waiting" = "Čeká na odpověď";
+"subscriptions__allowance_status_needs_answer" = "Čeká na vaši odpověď";
+"subscriptions__allowance_status_scheduled" = "Zatím neaktivní";
+"subscriptions__allowance_status_expired" = "Vypršelo";
+"subscriptions__allowance_status_declined" = "Odmítnuto";
+"subscriptions__allowance_status_conflicted" = "Vyžaduje pozornost";
+"subscriptions__allowance_status_ended" = "Ukončeno";
+"subscriptions__allowance_paid_automatically" = "{amount} zaplaceno automaticky";
+"subscriptions__allowance_paid_so_far" = "Zaplaceno automaticky";
+"subscriptions__allowance_offer_headline" = "{name} nabízí\npovolení plateb";
+"subscriptions__allowance_request_headline" = "{name} žádá o\npovolení plateb";
+"subscriptions__allowance_offer_explanation" = "Peněženka {name} bude platit vaše žádosti do těchto limitů, aniž by se pokaždé ptala. Kdokoli z vás ho může ukončit.";
+"subscriptions__allowance_request_explanation" = "Vaše peněženka bude platit žádosti {name} do těchto limitů, aniž by se vás pokaždé ptala. Kdokoli z vás ho může ukončit.";
+"subscriptions__allowance_accept" = "Přijmout";
+"subscriptions__allowance_decline" = "Odmítnout";
+"subscriptions__allowance_swipe_end" = "Přejeďte pro ukončení povolení";
+"subscriptions__allowance_swipe_withdraw" = "Přejeďte pro stažení";
+"subscriptions__allowance_detail_active_allower" = "Žádosti do těchto limitů se platí automaticky. U čehokoli nad nimi se vás nejprve zeptáme.";
+"subscriptions__allowance_detail_active_allowee" = "Vaše žádosti do těchto limitů se platí automaticky.";
+"subscriptions__allowance_detail_ended" = "Toto povolení skončilo. Každá žádost bude opět vyžadovat potvrzení.";
+"subscriptions__allowance_error_not_linked" = "Tento kontakt zatím není propojen. Zkuste to znovu za chvíli.";
+"subscriptions__allowance_error_unavailable" = "Toto povolení není momentálně k dispozici.";
+"subscriptions__allowance_payment_in_progress" = "Tato žádost se již platí.";
+"subscriptions__allowance_executed_title" = "Platba provedena";
+"subscriptions__allowance_executed_description" = "Automatická platba odeslala {amount} uživateli {name}";
+"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 e082793b2..ac1f3bf3a 100644
--- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
@@ -1900,3 +1900,50 @@
"usdt__deposit_refund_confirmation" = "Rückerstattung für die Einzahlung von {amount} USDT anfordern?\n\nNetzwerk:\n{network}\n\nRückerstattungsadresse:\n{address}\n\nStelle sicher, dass diese Adresse USDT auf {shortNetwork} empfangen kann. Es können Rückerstattungsgebühren anfallen.";
"usdt__deposit_tron_refund_note" = "TRON-Rückerstattungen können die Unterstützung eines Orchestra-Betreibers erfordern.";
"usdt__deposit_refund_address_network" = "USDT-Adresse auf {network}";
+"subscriptions__allowances" = "Kontingente";
+"subscriptions__allowances_empty_headline" = "Kontingente\neinrichten";
+"subscriptions__allowances_empty_description" = "Lege Ausgabenlimits fest, um Zahlungsanforderungen vertrauenswürdiger Kontakte automatisch zu bezahlen.";
+"subscriptions__allowance_add" = "Kontingent hinzufügen";
+"subscriptions__allowance_title" = "Kontingent";
+"subscriptions__allowance_choose_contact" = "Kontakt auswählen";
+"subscriptions__allowance_no_contacts" = "Füge zuerst einen Kontakt hinzu. Kontingente gelten für Zahlungsanforderungen deiner Kontakte.";
+"subscriptions__allowance_set_title" = "Kontingent festlegen";
+"subscriptions__allowance_set_explanation" = "Zahlungsanforderungen von {name} unterhalb dieser Limits automatisch genehmigen:";
+"subscriptions__allowance_payment_limit" = "Zahlungslimit";
+"subscriptions__allowance_monthly_allowance" = "Monatliches Kontingent";
+"subscriptions__allowance_set_summary" = "Anforderungen bis {perPayment} werden automatisch bezahlt, bis zu {monthly} im Monat, ohne dich jedes Mal zu fragen.";
+"subscriptions__allowance_save" = "Kontingent speichern";
+"subscriptions__allowance_monthly_limit" = "Monatliches Limit";
+"subscriptions__allowance_per_payment_short" = "{amount} pro Zahlung";
+"subscriptions__allowance_per_payment" = "Pro Zahlung";
+"subscriptions__allowance_each_month" = "Jeden Monat";
+"subscriptions__allowance_up_to" = "Bis zu {amount}";
+"subscriptions__allowance_status_active" = "Aktiv";
+"subscriptions__allowance_status_waiting" = "Wartet auf Antwort";
+"subscriptions__allowance_status_needs_answer" = "Wartet auf deine Antwort";
+"subscriptions__allowance_status_scheduled" = "Noch nicht aktiv";
+"subscriptions__allowance_status_expired" = "Abgelaufen";
+"subscriptions__allowance_status_declined" = "Abgelehnt";
+"subscriptions__allowance_status_conflicted" = "Prüfung nötig";
+"subscriptions__allowance_status_ended" = "Beendet";
+"subscriptions__allowance_paid_automatically" = "{amount} automatisch bezahlt";
+"subscriptions__allowance_paid_so_far" = "Automatisch bezahlt";
+"subscriptions__allowance_offer_headline" = "{name} bietet dir\nein Kontingent";
+"subscriptions__allowance_request_headline" = "{name} bittet um\nein Kontingent";
+"subscriptions__allowance_offer_explanation" = "Das Wallet von {name} bezahlt deine Anforderungen innerhalb dieser Limits, ohne jedes Mal nachzufragen. Jeder von euch kann es beenden.";
+"subscriptions__allowance_request_explanation" = "Dein Wallet bezahlt die Anforderungen von {name} innerhalb dieser Limits, ohne dich jedes Mal zu fragen. Jeder von euch kann es beenden.";
+"subscriptions__allowance_accept" = "Annehmen";
+"subscriptions__allowance_decline" = "Ablehnen";
+"subscriptions__allowance_swipe_end" = "Wischen zum Beenden";
+"subscriptions__allowance_swipe_withdraw" = "Wischen zum Zurückziehen";
+"subscriptions__allowance_detail_active_allower" = "Anforderungen innerhalb dieser Limits werden automatisch bezahlt. Alles darüber musst du zuerst bestätigen.";
+"subscriptions__allowance_detail_active_allowee" = "Deine Anforderungen innerhalb dieser Limits werden automatisch bezahlt.";
+"subscriptions__allowance_detail_ended" = "Dieses Kontingent ist beendet. Jede Anforderung muss wieder bestätigt werden.";
+"subscriptions__allowance_error_not_linked" = "Dieser Kontakt ist noch nicht verbunden. Versuche es gleich noch einmal.";
+"subscriptions__allowance_error_unavailable" = "Dieses Kontingent ist gerade nicht verfügbar.";
+"subscriptions__allowance_payment_in_progress" = "Diese Anforderung wird bereits bezahlt.";
+"subscriptions__allowance_executed_title" = "Zahlung ausgeführt";
+"subscriptions__allowance_executed_description" = "Auto-Pay hat {amount} an {name} gesendet";
+"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 a417f60da..6ac33f347 100644
--- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
@@ -1635,3 +1635,50 @@
"usdt__deposit_tron_refund_note" = "Οι επιστροφές στο TRON μπορεί να απαιτούν βοήθεια από χειριστή του Orchestra.";
"usdt__deposit_refund_address_network" = "Διεύθυνση USDT στο {network}";
"wallet__send_error_support" = "Επικοινωνία με Υποστήριξη";
+"subscriptions__allowances" = "Προεγκρίσεις";
+"subscriptions__allowances_empty_headline" = "Ρύθμισε\nπροεγκρίσεις";
+"subscriptions__allowances_empty_description" = "Μπορείς να ορίσεις όρια δαπανών για να εξοφλούνται αυτόματα αιτήματα πληρωμής από αξιόπιστες επαφές.";
+"subscriptions__allowance_add" = "Προσθήκη προέγκρισης";
+"subscriptions__allowance_title" = "Προέγκριση";
+"subscriptions__allowance_choose_contact" = "Επιλογή επαφής";
+"subscriptions__allowance_no_contacts" = "Πρόσθεσε πρώτα μια επαφή. Οι προεγκρίσεις καλύπτουν αιτήματα πληρωμής από τις επαφές σου.";
+"subscriptions__allowance_set_title" = "Ορισμός προέγκρισης";
+"subscriptions__allowance_set_explanation" = "Αυτόματη έγκριση αιτημάτων πληρωμής από {name} κάτω από αυτά τα όρια:";
+"subscriptions__allowance_payment_limit" = "Όριο πληρωμής";
+"subscriptions__allowance_monthly_allowance" = "Μηνιαίο ποσό";
+"subscriptions__allowance_set_summary" = "Τα αιτήματα έως {perPayment} θα πληρώνονται αυτόματα, έως {monthly} τον μήνα, χωρίς να σε ρωτάμε κάθε φορά.";
+"subscriptions__allowance_save" = "Αποθήκευση προέγκρισης";
+"subscriptions__allowance_monthly_limit" = "Μηνιαίο όριο";
+"subscriptions__allowance_per_payment_short" = "{amount} ανά πληρωμή";
+"subscriptions__allowance_per_payment" = "Ανά πληρωμή";
+"subscriptions__allowance_each_month" = "Κάθε μήνα";
+"subscriptions__allowance_up_to" = "Έως {amount}";
+"subscriptions__allowance_status_active" = "Ενεργή";
+"subscriptions__allowance_status_waiting" = "Αναμονή απάντησης";
+"subscriptions__allowance_status_needs_answer" = "Αναμένεται η απάντησή σου";
+"subscriptions__allowance_status_scheduled" = "Δεν είναι ακόμη ενεργή";
+"subscriptions__allowance_status_expired" = "Έληξε";
+"subscriptions__allowance_status_declined" = "Απορρίφθηκε";
+"subscriptions__allowance_status_conflicted" = "Χρειάζεται προσοχή";
+"subscriptions__allowance_status_ended" = "Τερματίστηκε";
+"subscriptions__allowance_paid_automatically" = "{amount} πληρώθηκαν αυτόματα";
+"subscriptions__allowance_paid_so_far" = "Πληρώθηκαν αυτόματα";
+"subscriptions__allowance_offer_headline" = "{name} προσφέρει\nμια προέγκριση";
+"subscriptions__allowance_request_headline" = "{name} ζητά\nμια προέγκριση";
+"subscriptions__allowance_offer_explanation" = "Το πορτοφόλι του/της {name} θα πληρώνει τα αιτήματά σου εντός αυτών των ορίων χωρίς να ρωτά κάθε φορά. Οποιοσδήποτε από εσάς μπορεί να την τερματίσει.";
+"subscriptions__allowance_request_explanation" = "Το πορτοφόλι σου θα πληρώνει τα αιτήματα του/της {name} εντός αυτών των ορίων χωρίς να σε ρωτά κάθε φορά. Οποιοσδήποτε από εσάς μπορεί να την τερματίσει.";
+"subscriptions__allowance_accept" = "Αποδοχή";
+"subscriptions__allowance_decline" = "Απόρριψη";
+"subscriptions__allowance_swipe_end" = "Σύρε για τερματισμό προέγκρισης";
+"subscriptions__allowance_swipe_withdraw" = "Σύρε για απόσυρση";
+"subscriptions__allowance_detail_active_allower" = "Τα αιτήματα εντός αυτών των ορίων πληρώνονται αυτόματα. Για οτιδήποτε τα υπερβαίνει, θα ερωτηθείς πρώτα.";
+"subscriptions__allowance_detail_active_allowee" = "Τα αιτήματά σου εντός αυτών των ορίων πληρώνονται αυτόματα.";
+"subscriptions__allowance_detail_ended" = "Αυτή η προέγκριση έχει τερματιστεί. Κάθε αίτημα θα χρειάζεται ξανά έγκριση.";
+"subscriptions__allowance_error_not_linked" = "Αυτή η επαφή δεν έχει συνδεθεί ακόμη. Δοκίμασε ξανά σε λίγο.";
+"subscriptions__allowance_error_unavailable" = "Αυτή η προέγκριση δεν είναι διαθέσιμη αυτή τη στιγμή.";
+"subscriptions__allowance_payment_in_progress" = "Αυτό το αίτημα πληρώνεται ήδη.";
+"subscriptions__allowance_executed_title" = "Η πληρωμή εκτελέστηκε";
+"subscriptions__allowance_executed_description" = "Η αυτόματη πληρωμή έστειλε {amount} σε {name}";
+"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 07b8118fb..3ecc58aae 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -1775,6 +1775,53 @@
"settings__adv__pp_both" = "Both";
"settings__adv__pp_lightning_short" = "Lightning";
"settings__adv__pp_onchain_short" = "On-chain";
+"subscriptions__allowances" = "Allowances";
+"subscriptions__allowances_empty_headline" = "Set up\nallowances";
+"subscriptions__allowances_empty_description" = "You can set spending limits to automatically fulfill payment requests from trusted peers.";
+"subscriptions__allowance_add" = "Add Allowance";
+"subscriptions__allowance_title" = "Allowance";
+"subscriptions__allowance_choose_contact" = "Choose Contact";
+"subscriptions__allowance_no_contacts" = "Add a contact first. Allowances cover payment requests from your contacts.";
+"subscriptions__allowance_set_title" = "Set Allowance";
+"subscriptions__allowance_set_explanation" = "Automatically approve payment requests from {name} below these limits:";
+"subscriptions__allowance_payment_limit" = "Payment limit";
+"subscriptions__allowance_monthly_allowance" = "Monthly allowance";
+"subscriptions__allowance_set_summary" = "Requests up to {perPayment} will be paid automatically, up to {monthly} a month, without asking you each time.";
+"subscriptions__allowance_save" = "Save Allowance";
+"subscriptions__allowance_monthly_limit" = "Monthly limit";
+"subscriptions__allowance_per_payment_short" = "{amount} a payment";
+"subscriptions__allowance_per_payment" = "Per payment";
+"subscriptions__allowance_each_month" = "Each month";
+"subscriptions__allowance_up_to" = "Up to {amount}";
+"subscriptions__allowance_status_active" = "Active";
+"subscriptions__allowance_status_waiting" = "Waiting for an answer";
+"subscriptions__allowance_status_needs_answer" = "Waiting for your answer";
+"subscriptions__allowance_status_scheduled" = "Not active yet";
+"subscriptions__allowance_status_expired" = "Expired";
+"subscriptions__allowance_status_declined" = "Declined";
+"subscriptions__allowance_status_conflicted" = "Needs attention";
+"subscriptions__allowance_status_ended" = "Ended";
+"subscriptions__allowance_paid_automatically" = "{amount} paid automatically";
+"subscriptions__allowance_paid_so_far" = "Paid automatically";
+"subscriptions__allowance_offer_headline" = "{name} offers\nan allowance";
+"subscriptions__allowance_request_headline" = "{name} asks for\nan allowance";
+"subscriptions__allowance_offer_explanation" = "{name}'s wallet will pay your requests within these limits without asking each time. Either of you can end it.";
+"subscriptions__allowance_request_explanation" = "Your wallet will pay {name}'s requests within these limits without asking you each time. Either of you can end it.";
+"subscriptions__allowance_accept" = "Accept";
+"subscriptions__allowance_decline" = "Decline";
+"subscriptions__allowance_swipe_end" = "Swipe To End Allowance";
+"subscriptions__allowance_swipe_withdraw" = "Swipe To Withdraw";
+"subscriptions__allowance_detail_active_allower" = "Requests within these limits are paid automatically. Anything above them asks you first.";
+"subscriptions__allowance_detail_active_allowee" = "Your requests within these limits are paid automatically.";
+"subscriptions__allowance_detail_ended" = "This allowance has ended. Every request asks again.";
+"subscriptions__allowance_error_not_linked" = "This contact is not connected yet. Try again in a moment.";
+"subscriptions__allowance_error_unavailable" = "This allowance is not available right now.";
+"subscriptions__allowance_payment_in_progress" = "This request is already being paid.";
+"subscriptions__allowance_executed_title" = "Payment Executed";
+"subscriptions__allowance_executed_description" = "Auto-pay sent {amount} to {name}";
+"subscriptions__allowance_limit_title" = "Limit Reached";
+"subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay.";
+"subscriptions__allowance_auto_paid" = "Auto-paid";
// On-chain acceptance and original-payment follow-up
"wallet__onchain_send_unresolved" = "An earlier on-chain send is unresolved. Check its transaction before trying another send.";
diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
index ba9bf100c..c49ada846 100644
--- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
@@ -1906,3 +1906,50 @@
"usdt__deposit_refund_confirmation" = "¿Solicitar el reembolso del depósito de {amount} USDT?\n\nRed:\n{network}\n\nDirección de reembolso:\n{address}\n\nConfirme que esta dirección puede recibir USDT en {shortNetwork}. Es posible que se apliquen comisiones de reembolso.";
"usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra.";
"usdt__deposit_refund_address_network" = "Dirección USDT en {network}";
+"subscriptions__allowances" = "Asignaciones";
+"subscriptions__allowances_empty_headline" = "Configure\nasignaciones";
+"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza.";
+"subscriptions__allowance_add" = "Añadir asignación";
+"subscriptions__allowance_title" = "Asignación";
+"subscriptions__allowance_choose_contact" = "Elegir contacto";
+"subscriptions__allowance_no_contacts" = "Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos.";
+"subscriptions__allowance_set_title" = "Establecer asignación";
+"subscriptions__allowance_set_explanation" = "Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites:";
+"subscriptions__allowance_payment_limit" = "Límite por pago";
+"subscriptions__allowance_monthly_allowance" = "Asignación mensual";
+"subscriptions__allowance_set_summary" = "Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} por mes, sin preguntarle cada vez.";
+"subscriptions__allowance_save" = "Guardar asignación";
+"subscriptions__allowance_monthly_limit" = "Límite mensual";
+"subscriptions__allowance_per_payment_short" = "{amount} por pago";
+"subscriptions__allowance_per_payment" = "Por pago";
+"subscriptions__allowance_each_month" = "Cada mes";
+"subscriptions__allowance_up_to" = "Hasta {amount}";
+"subscriptions__allowance_status_active" = "Activa";
+"subscriptions__allowance_status_waiting" = "Esperando respuesta";
+"subscriptions__allowance_status_needs_answer" = "Esperando su respuesta";
+"subscriptions__allowance_status_scheduled" = "Aún no activa";
+"subscriptions__allowance_status_expired" = "Vencida";
+"subscriptions__allowance_status_declined" = "Rechazada";
+"subscriptions__allowance_status_conflicted" = "Requiere atención";
+"subscriptions__allowance_status_ended" = "Finalizada";
+"subscriptions__allowance_paid_automatically" = "{amount} pagado automáticamente";
+"subscriptions__allowance_paid_so_far" = "Pagado automáticamente";
+"subscriptions__allowance_offer_headline" = "{name} le ofrece\nuna asignación";
+"subscriptions__allowance_request_headline" = "{name} solicita\nuna asignación";
+"subscriptions__allowance_offer_explanation" = "La billetera de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla.";
+"subscriptions__allowance_request_explanation" = "Su billetera pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla.";
+"subscriptions__allowance_accept" = "Aceptar";
+"subscriptions__allowance_decline" = "Rechazar";
+"subscriptions__allowance_swipe_end" = "Deslice para finalizar la asignación";
+"subscriptions__allowance_swipe_withdraw" = "Deslice para retirar";
+"subscriptions__allowance_detail_active_allower" = "Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier monto superior se le preguntará antes.";
+"subscriptions__allowance_detail_active_allowee" = "Sus solicitudes dentro de estos límites se pagan automáticamente.";
+"subscriptions__allowance_detail_ended" = "Esta asignación finalizó. Cada solicitud volverá a requerir su confirmación.";
+"subscriptions__allowance_error_not_linked" = "Este contacto aún no está conectado. Inténtelo de nuevo en un momento.";
+"subscriptions__allowance_error_unavailable" = "Esta asignación no está disponible en este momento.";
+"subscriptions__allowance_payment_in_progress" = "Esta solicitud ya se está pagando.";
+"subscriptions__allowance_executed_title" = "Pago ejecutado";
+"subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}";
+"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 d9c7913bd..40cbbb900 100644
--- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
@@ -1784,3 +1784,50 @@
"usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra.";
"usdt__deposit_refund_address_network" = "Dirección USDT en {network}";
"wallet__send_error_support" = "Contactar con el servicio de asistencia";
+"subscriptions__allowances" = "Asignaciones";
+"subscriptions__allowances_empty_headline" = "Configure\nasignaciones";
+"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza.";
+"subscriptions__allowance_add" = "Añadir asignación";
+"subscriptions__allowance_title" = "Asignación";
+"subscriptions__allowance_choose_contact" = "Elegir contacto";
+"subscriptions__allowance_no_contacts" = "Añada primero un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos.";
+"subscriptions__allowance_set_title" = "Establecer asignación";
+"subscriptions__allowance_set_explanation" = "Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites:";
+"subscriptions__allowance_payment_limit" = "Límite por pago";
+"subscriptions__allowance_monthly_allowance" = "Asignación mensual";
+"subscriptions__allowance_set_summary" = "Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} al mes, sin preguntarle cada vez.";
+"subscriptions__allowance_save" = "Guardar asignación";
+"subscriptions__allowance_monthly_limit" = "Límite mensual";
+"subscriptions__allowance_per_payment_short" = "{amount} por pago";
+"subscriptions__allowance_per_payment" = "Por pago";
+"subscriptions__allowance_each_month" = "Cada mes";
+"subscriptions__allowance_up_to" = "Hasta {amount}";
+"subscriptions__allowance_status_active" = "Activa";
+"subscriptions__allowance_status_waiting" = "Esperando respuesta";
+"subscriptions__allowance_status_needs_answer" = "Esperando su respuesta";
+"subscriptions__allowance_status_scheduled" = "Aún no activa";
+"subscriptions__allowance_status_expired" = "Caducada";
+"subscriptions__allowance_status_declined" = "Rechazada";
+"subscriptions__allowance_status_conflicted" = "Requiere atención";
+"subscriptions__allowance_status_ended" = "Finalizada";
+"subscriptions__allowance_paid_automatically" = "{amount} pagado automáticamente";
+"subscriptions__allowance_paid_so_far" = "Pagado automáticamente";
+"subscriptions__allowance_offer_headline" = "{name} le ofrece\nuna asignación";
+"subscriptions__allowance_request_headline" = "{name} solicita\nuna asignación";
+"subscriptions__allowance_offer_explanation" = "El monedero de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla.";
+"subscriptions__allowance_request_explanation" = "Su monedero pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla.";
+"subscriptions__allowance_accept" = "Aceptar";
+"subscriptions__allowance_decline" = "Rechazar";
+"subscriptions__allowance_swipe_end" = "Deslizar para finalizar la asignación";
+"subscriptions__allowance_swipe_withdraw" = "Deslizar para retirar";
+"subscriptions__allowance_detail_active_allower" = "Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier importe superior se le preguntará antes.";
+"subscriptions__allowance_detail_active_allowee" = "Sus solicitudes dentro de estos límites se pagan automáticamente.";
+"subscriptions__allowance_detail_ended" = "Esta asignación ha finalizado. Cada solicitud volverá a requerir su confirmación.";
+"subscriptions__allowance_error_not_linked" = "Este contacto aún no está conectado. Inténtelo de nuevo en un momento.";
+"subscriptions__allowance_error_unavailable" = "Esta asignación no está disponible en este momento.";
+"subscriptions__allowance_payment_in_progress" = "Esta solicitud ya se está pagando.";
+"subscriptions__allowance_executed_title" = "Pago ejecutado";
+"subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}";
+"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 52e840f68..00df90320 100644
--- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
@@ -1914,3 +1914,50 @@
"usdt__deposit_refund_confirmation" = "Demander le remboursement du dépôt de {amount} USDT ?\n\nRéseau :\n{network}\n\nAdresse de remboursement :\n{address}\n\nVérifiez que cette adresse peut recevoir des USDT sur {shortNetwork}. Des frais de remboursement peuvent s'appliquer.";
"usdt__deposit_tron_refund_note" = "Les remboursements TRON peuvent nécessiter l'intervention d'un opérateur Orchestra.";
"usdt__deposit_refund_address_network" = "Adresse USDT sur {network}";
+"subscriptions__allowances" = "Allocations";
+"subscriptions__allowances_empty_headline" = "Configurez\ndes allocations";
+"subscriptions__allowances_empty_description" = "Vous pouvez définir des limites de dépenses pour régler automatiquement les demandes de paiement de contacts de confiance.";
+"subscriptions__allowance_add" = "Ajouter une allocation";
+"subscriptions__allowance_title" = "Allocation";
+"subscriptions__allowance_choose_contact" = "Choisir un contact";
+"subscriptions__allowance_no_contacts" = "Ajoutez d'abord un contact. Les allocations couvrent les demandes de paiement de vos contacts.";
+"subscriptions__allowance_set_title" = "Définir l'allocation";
+"subscriptions__allowance_set_explanation" = "Approuver automatiquement les demandes de paiement de {name} sous ces limites :";
+"subscriptions__allowance_payment_limit" = "Limite par paiement";
+"subscriptions__allowance_monthly_allowance" = "Allocation mensuelle";
+"subscriptions__allowance_set_summary" = "Les demandes jusqu'à {perPayment} seront payées automatiquement, jusqu'à {monthly} par mois, sans vous demander à chaque fois.";
+"subscriptions__allowance_save" = "Enregistrer l'allocation";
+"subscriptions__allowance_monthly_limit" = "Limite mensuelle";
+"subscriptions__allowance_per_payment_short" = "{amount} par paiement";
+"subscriptions__allowance_per_payment" = "Par paiement";
+"subscriptions__allowance_each_month" = "Chaque mois";
+"subscriptions__allowance_up_to" = "Jusqu'à {amount}";
+"subscriptions__allowance_status_active" = "Active";
+"subscriptions__allowance_status_waiting" = "En attente de réponse";
+"subscriptions__allowance_status_needs_answer" = "En attente de votre réponse";
+"subscriptions__allowance_status_scheduled" = "Pas encore active";
+"subscriptions__allowance_status_expired" = "Expirée";
+"subscriptions__allowance_status_declined" = "Refusée";
+"subscriptions__allowance_status_conflicted" = "Action requise";
+"subscriptions__allowance_status_ended" = "Terminée";
+"subscriptions__allowance_paid_automatically" = "{amount} payé automatiquement";
+"subscriptions__allowance_paid_so_far" = "Payé automatiquement";
+"subscriptions__allowance_offer_headline" = "{name} vous propose\nune allocation";
+"subscriptions__allowance_request_headline" = "{name} demande\nune allocation";
+"subscriptions__allowance_offer_explanation" = "Le portefeuille de {name} paiera vos demandes dans ces limites sans demander à chaque fois. Chacun de vous peut y mettre fin.";
+"subscriptions__allowance_request_explanation" = "Votre portefeuille paiera les demandes de {name} dans ces limites sans vous demander à chaque fois. Chacun de vous peut y mettre fin.";
+"subscriptions__allowance_accept" = "Accepter";
+"subscriptions__allowance_decline" = "Refuser";
+"subscriptions__allowance_swipe_end" = "Glissez pour mettre fin à l'allocation";
+"subscriptions__allowance_swipe_withdraw" = "Glissez pour retirer";
+"subscriptions__allowance_detail_active_allower" = "Les demandes dans ces limites sont payées automatiquement. Au-delà, votre accord vous sera d'abord demandé.";
+"subscriptions__allowance_detail_active_allowee" = "Vos demandes dans ces limites sont payées automatiquement.";
+"subscriptions__allowance_detail_ended" = "Cette allocation a pris fin. Chaque demande nécessite à nouveau votre accord.";
+"subscriptions__allowance_error_not_linked" = "Ce contact n'est pas encore connecté. Réessayez dans un instant.";
+"subscriptions__allowance_error_unavailable" = "Cette allocation n'est pas disponible pour le moment.";
+"subscriptions__allowance_payment_in_progress" = "Cette demande est déjà en cours de paiement.";
+"subscriptions__allowance_executed_title" = "Paiement effectué";
+"subscriptions__allowance_executed_description" = "Le paiement automatique a envoyé {amount} à {name}";
+"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 a65e4b51e..239133b7a 100644
--- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
@@ -1873,3 +1873,50 @@
"usdt__deposit_refund_confirmation" = "Richiedere il rimborso del deposito di {amount} USDT?\n\nRete:\n{network}\n\nIndirizzo di rimborso:\n{address}\n\nVerifica che questo indirizzo possa ricevere USDT su {shortNetwork}. Potrebbero essere applicate commissioni di rimborso.";
"usdt__deposit_tron_refund_note" = "I rimborsi TRON possono richiedere l'intervento di un operatore Orchestra.";
"usdt__deposit_refund_address_network" = "Indirizzo USDT su {network}";
+"subscriptions__allowances" = "Plafond";
+"subscriptions__allowances_empty_headline" = "Imposta\ni plafond";
+"subscriptions__allowances_empty_description" = "Puoi impostare limiti di spesa per pagare automaticamente le richieste di pagamento dei contatti fidati.";
+"subscriptions__allowance_add" = "Aggiungi Plafond";
+"subscriptions__allowance_title" = "Plafond";
+"subscriptions__allowance_choose_contact" = "Scegli Contatto";
+"subscriptions__allowance_no_contacts" = "Aggiungi prima un contatto. I plafond coprono le richieste di pagamento dei tuoi contatti.";
+"subscriptions__allowance_set_title" = "Imposta Plafond";
+"subscriptions__allowance_set_explanation" = "Approva automaticamente le richieste di pagamento di {name} entro questi limiti:";
+"subscriptions__allowance_payment_limit" = "Limite per pagamento";
+"subscriptions__allowance_monthly_allowance" = "Plafond mensile";
+"subscriptions__allowance_set_summary" = "Le richieste fino a {perPayment} verranno pagate automaticamente, fino a {monthly} al mese, senza chiederti ogni volta.";
+"subscriptions__allowance_save" = "Salva Plafond";
+"subscriptions__allowance_monthly_limit" = "Limite mensile";
+"subscriptions__allowance_per_payment_short" = "{amount} a pagamento";
+"subscriptions__allowance_per_payment" = "Per pagamento";
+"subscriptions__allowance_each_month" = "Ogni mese";
+"subscriptions__allowance_up_to" = "Fino a {amount}";
+"subscriptions__allowance_status_active" = "Attivo";
+"subscriptions__allowance_status_waiting" = "In attesa di risposta";
+"subscriptions__allowance_status_needs_answer" = "In attesa della tua risposta";
+"subscriptions__allowance_status_scheduled" = "Non ancora attivo";
+"subscriptions__allowance_status_expired" = "Scaduto";
+"subscriptions__allowance_status_declined" = "Rifiutato";
+"subscriptions__allowance_status_conflicted" = "Richiede attenzione";
+"subscriptions__allowance_status_ended" = "Terminato";
+"subscriptions__allowance_paid_automatically" = "{amount} pagati automaticamente";
+"subscriptions__allowance_paid_so_far" = "Pagato automaticamente";
+"subscriptions__allowance_offer_headline" = "{name} ti offre\nun plafond";
+"subscriptions__allowance_request_headline" = "{name} chiede\nun plafond";
+"subscriptions__allowance_offer_explanation" = "Il wallet di {name} pagherà le tue richieste entro questi limiti senza chiedere ogni volta. Ognuno di voi può terminarlo.";
+"subscriptions__allowance_request_explanation" = "Il tuo wallet pagherà le richieste di {name} entro questi limiti senza chiederti ogni volta. Ognuno di voi può terminarlo.";
+"subscriptions__allowance_accept" = "Accetta";
+"subscriptions__allowance_decline" = "Rifiuta";
+"subscriptions__allowance_swipe_end" = "Scorri Per Terminare Il Plafond";
+"subscriptions__allowance_swipe_withdraw" = "Scorri Per Ritirare";
+"subscriptions__allowance_detail_active_allower" = "Le richieste entro questi limiti vengono pagate automaticamente. Per importi superiori ti verrà chiesto prima.";
+"subscriptions__allowance_detail_active_allowee" = "Le tue richieste entro questi limiti vengono pagate automaticamente.";
+"subscriptions__allowance_detail_ended" = "Questo plafond è terminato. Ogni richiesta richiederà di nuovo la tua conferma.";
+"subscriptions__allowance_error_not_linked" = "Questo contatto non è ancora collegato. Riprova tra un momento.";
+"subscriptions__allowance_error_unavailable" = "Questo plafond non è disponibile al momento.";
+"subscriptions__allowance_payment_in_progress" = "Questa richiesta è già in fase di pagamento.";
+"subscriptions__allowance_executed_title" = "Pagamento Eseguito";
+"subscriptions__allowance_executed_description" = "Il pagamento automatico ha inviato {amount} a {name}";
+"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 5db94734b..b35b29906 100644
--- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
@@ -1908,3 +1908,50 @@
"usdt__deposit_refund_confirmation" = "Terugbetaling aanvragen voor storting van {amount} USDT?\n\nNetwerk:\n{network}\n\nTerugbetalingsadres:\n{address}\n\nControleer of dit adres USDT kan ontvangen op {shortNetwork}. Er kunnen terugbetalingskosten gelden.";
"usdt__deposit_tron_refund_note" = "Voor TRON-terugbetalingen kan hulp van een Orchestra-operator nodig zijn.";
"usdt__deposit_refund_address_network" = "USDT-adres op {network}";
+"subscriptions__allowances" = "Toelagen";
+"subscriptions__allowances_empty_headline" = "Stel\ntoelagen in";
+"subscriptions__allowances_empty_description" = "U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen.";
+"subscriptions__allowance_add" = "Toelage toevoegen";
+"subscriptions__allowance_title" = "Toelage";
+"subscriptions__allowance_choose_contact" = "Contact kiezen";
+"subscriptions__allowance_no_contacts" = "Voeg eerst een contact toe. Toelagen gelden voor betaalverzoeken van uw contacten.";
+"subscriptions__allowance_set_title" = "Toelage instellen";
+"subscriptions__allowance_set_explanation" = "Betaalverzoeken van {name} onder deze limieten automatisch goedkeuren:";
+"subscriptions__allowance_payment_limit" = "Betaallimiet";
+"subscriptions__allowance_monthly_allowance" = "Maandelijkse toelage";
+"subscriptions__allowance_set_summary" = "Verzoeken tot {perPayment} worden automatisch betaald, tot {monthly} per maand, zonder het u telkens te vragen.";
+"subscriptions__allowance_save" = "Toelage opslaan";
+"subscriptions__allowance_monthly_limit" = "Maandelijkse limiet";
+"subscriptions__allowance_per_payment_short" = "{amount} per betaling";
+"subscriptions__allowance_per_payment" = "Per betaling";
+"subscriptions__allowance_each_month" = "Elke maand";
+"subscriptions__allowance_up_to" = "Tot {amount}";
+"subscriptions__allowance_status_active" = "Actief";
+"subscriptions__allowance_status_waiting" = "Wacht op antwoord";
+"subscriptions__allowance_status_needs_answer" = "Wacht op uw antwoord";
+"subscriptions__allowance_status_scheduled" = "Nog niet actief";
+"subscriptions__allowance_status_expired" = "Verlopen";
+"subscriptions__allowance_status_declined" = "Geweigerd";
+"subscriptions__allowance_status_conflicted" = "Aandacht nodig";
+"subscriptions__allowance_status_ended" = "Beëindigd";
+"subscriptions__allowance_paid_automatically" = "{amount} automatisch betaald";
+"subscriptions__allowance_paid_so_far" = "Automatisch betaald";
+"subscriptions__allowance_offer_headline" = "{name} biedt\neen toelage aan";
+"subscriptions__allowance_request_headline" = "{name} vraagt om\neen toelage";
+"subscriptions__allowance_offer_explanation" = "De wallet van {name} betaalt uw verzoeken binnen deze limieten zonder telkens te vragen. U kunt deze allebei beëindigen.";
+"subscriptions__allowance_request_explanation" = "Uw wallet betaalt de verzoeken van {name} binnen deze limieten zonder het u telkens te vragen. U kunt deze allebei beëindigen.";
+"subscriptions__allowance_accept" = "Accepteren";
+"subscriptions__allowance_decline" = "Weigeren";
+"subscriptions__allowance_swipe_end" = "Veeg om toelage te beëindigen";
+"subscriptions__allowance_swipe_withdraw" = "Veeg om in te trekken";
+"subscriptions__allowance_detail_active_allower" = "Verzoeken binnen deze limieten worden automatisch betaald. Voor alles daarboven wordt u eerst om toestemming gevraagd.";
+"subscriptions__allowance_detail_active_allowee" = "Uw verzoeken binnen deze limieten worden automatisch betaald.";
+"subscriptions__allowance_detail_ended" = "Deze toelage is beëindigd. Elk verzoek vraagt weer om toestemming.";
+"subscriptions__allowance_error_not_linked" = "Dit contact is nog niet verbonden. Probeer het zo opnieuw.";
+"subscriptions__allowance_error_unavailable" = "Deze toelage is momenteel niet beschikbaar.";
+"subscriptions__allowance_payment_in_progress" = "Dit verzoek wordt al betaald.";
+"subscriptions__allowance_executed_title" = "Betaling uitgevoerd";
+"subscriptions__allowance_executed_description" = "Automatisch betalen heeft {amount} naar {name} gestuurd";
+"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 afcd7b4a1..b21506d62 100644
--- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
@@ -1913,3 +1913,50 @@
"usdt__deposit_refund_confirmation" = "Poprosić o zwrot wpłaty {amount} USDT?\n\nSieć:\n{network}\n\nAdres zwrotu:\n{address}\n\nUpewnij się, że ten adres może odbierać USDT w sieci {shortNetwork}. Mogą obowiązywać opłaty za zwrot.";
"usdt__deposit_tron_refund_note" = "Zwroty w sieci TRON mogą wymagać pomocy operatora Orchestra.";
"usdt__deposit_refund_address_network" = "Adres USDT w sieci {network}";
+"subscriptions__allowances" = "Przydziały";
+"subscriptions__allowances_empty_headline" = "Ustaw\nprzydziały";
+"subscriptions__allowances_empty_description" = "Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów.";
+"subscriptions__allowance_add" = "Dodaj przydział";
+"subscriptions__allowance_title" = "Przydział";
+"subscriptions__allowance_choose_contact" = "Wybierz kontakt";
+"subscriptions__allowance_no_contacts" = "Najpierw dodaj kontakt. Przydziały obejmują żądania płatności od Twoich kontaktów.";
+"subscriptions__allowance_set_title" = "Ustaw przydział";
+"subscriptions__allowance_set_explanation" = "Automatycznie zatwierdzaj żądania płatności od {name} poniżej tych limitów:";
+"subscriptions__allowance_payment_limit" = "Limit płatności";
+"subscriptions__allowance_monthly_allowance" = "Miesięczny przydział";
+"subscriptions__allowance_set_summary" = "Żądania do {perPayment} będą opłacane automatycznie, do {monthly} miesięcznie, bez pytania Cię za każdym razem.";
+"subscriptions__allowance_save" = "Zapisz przydział";
+"subscriptions__allowance_monthly_limit" = "Limit miesięczny";
+"subscriptions__allowance_per_payment_short" = "{amount} za płatność";
+"subscriptions__allowance_per_payment" = "Za płatność";
+"subscriptions__allowance_each_month" = "Co miesiąc";
+"subscriptions__allowance_up_to" = "Do {amount}";
+"subscriptions__allowance_status_active" = "Aktywny";
+"subscriptions__allowance_status_waiting" = "Czeka na odpowiedź";
+"subscriptions__allowance_status_needs_answer" = "Czeka na Twoją odpowiedź";
+"subscriptions__allowance_status_scheduled" = "Jeszcze nieaktywny";
+"subscriptions__allowance_status_expired" = "Wygasł";
+"subscriptions__allowance_status_declined" = "Odrzucony";
+"subscriptions__allowance_status_conflicted" = "Wymaga uwagi";
+"subscriptions__allowance_status_ended" = "Zakończony";
+"subscriptions__allowance_paid_automatically" = "{amount} opłacono automatycznie";
+"subscriptions__allowance_paid_so_far" = "Opłacono automatycznie";
+"subscriptions__allowance_offer_headline" = "{name} oferuje\nprzydział";
+"subscriptions__allowance_request_headline" = "{name} prosi o\nprzydział";
+"subscriptions__allowance_offer_explanation" = "Portfel {name} będzie opłacać Twoje żądania w tych limitach bez pytania za każdym razem. Każde z was może go zakończyć.";
+"subscriptions__allowance_request_explanation" = "Twój portfel będzie opłacać żądania {name} w tych limitach bez pytania Cię za każdym razem. Każde z was może go zakończyć.";
+"subscriptions__allowance_accept" = "Akceptuj";
+"subscriptions__allowance_decline" = "Odrzuć";
+"subscriptions__allowance_swipe_end" = "Przesuń, aby zakończyć przydział";
+"subscriptions__allowance_swipe_withdraw" = "Przesuń, aby wycofać";
+"subscriptions__allowance_detail_active_allower" = "Żądania w tych limitach są opłacane automatycznie. O wszystko powyżej zapytamy Cię najpierw.";
+"subscriptions__allowance_detail_active_allowee" = "Twoje żądania w tych limitach są opłacane automatycznie.";
+"subscriptions__allowance_detail_ended" = "Ten przydział się zakończył. Każde żądanie znów wymaga potwierdzenia.";
+"subscriptions__allowance_error_not_linked" = "Ten kontakt nie jest jeszcze połączony. Spróbuj ponownie za chwilę.";
+"subscriptions__allowance_error_unavailable" = "Ten przydział jest teraz niedostępny.";
+"subscriptions__allowance_payment_in_progress" = "To żądanie jest już opłacane.";
+"subscriptions__allowance_executed_title" = "Płatność wykonana";
+"subscriptions__allowance_executed_description" = "Autopłatność wysłała {amount} do {name}";
+"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 c84d25a17..bed11492c 100644
--- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
@@ -1914,3 +1914,50 @@
"usdt__deposit_refund_confirmation" = "Solicitar reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Taxas de reembolso podem ser cobradas.";
"usdt__deposit_tron_refund_note" = "Reembolsos na TRON podem exigir assistência do operador da Orchestra.";
"usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}";
+"subscriptions__allowances" = "Pré-aprovações";
+"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações";
+"subscriptions__allowances_empty_description" = "Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos confiáveis.";
+"subscriptions__allowance_add" = "Adicionar Pré-aprovação";
+"subscriptions__allowance_title" = "Pré-aprovação";
+"subscriptions__allowance_choose_contact" = "Escolher Contato";
+"subscriptions__allowance_no_contacts" = "Adicione um contato primeiro. As pré-aprovações cobrem solicitações de pagamento dos seus contatos.";
+"subscriptions__allowance_set_title" = "Definir Pré-aprovação";
+"subscriptions__allowance_set_explanation" = "Aprovar automaticamente solicitações de pagamento de {name} abaixo destes limites:";
+"subscriptions__allowance_payment_limit" = "Limite por pagamento";
+"subscriptions__allowance_monthly_allowance" = "Valor mensal";
+"subscriptions__allowance_set_summary" = "Solicitações de até {perPayment} serão pagas automaticamente, até {monthly} por mês, sem perguntar a você toda vez.";
+"subscriptions__allowance_save" = "Salvar Pré-aprovação";
+"subscriptions__allowance_monthly_limit" = "Limite mensal";
+"subscriptions__allowance_per_payment_short" = "{amount} por pagamento";
+"subscriptions__allowance_per_payment" = "Por pagamento";
+"subscriptions__allowance_each_month" = "Todo mês";
+"subscriptions__allowance_up_to" = "Até {amount}";
+"subscriptions__allowance_status_active" = "Ativa";
+"subscriptions__allowance_status_waiting" = "Aguardando resposta";
+"subscriptions__allowance_status_needs_answer" = "Aguardando sua resposta";
+"subscriptions__allowance_status_scheduled" = "Ainda não ativa";
+"subscriptions__allowance_status_expired" = "Expirada";
+"subscriptions__allowance_status_declined" = "Recusada";
+"subscriptions__allowance_status_conflicted" = "Requer atenção";
+"subscriptions__allowance_status_ended" = "Encerrada";
+"subscriptions__allowance_paid_automatically" = "{amount} pago automaticamente";
+"subscriptions__allowance_paid_so_far" = "Pago automaticamente";
+"subscriptions__allowance_offer_headline" = "{name} oferece\numa pré-aprovação";
+"subscriptions__allowance_request_headline" = "{name} pede\numa pré-aprovação";
+"subscriptions__allowance_offer_explanation" = "A carteira de {name} pagará suas solicitações dentro destes limites sem perguntar toda vez. Qualquer um de vocês pode encerrá-la.";
+"subscriptions__allowance_request_explanation" = "Sua carteira pagará as solicitações de {name} dentro destes limites sem perguntar a você toda vez. Qualquer um de vocês pode encerrá-la.";
+"subscriptions__allowance_accept" = "Aceitar";
+"subscriptions__allowance_decline" = "Recusar";
+"subscriptions__allowance_swipe_end" = "Deslize para Encerrar a Pré-aprovação";
+"subscriptions__allowance_swipe_withdraw" = "Deslize para Retirar";
+"subscriptions__allowance_detail_active_allower" = "Solicitações dentro destes limites são pagas automaticamente. Qualquer valor acima pede sua confirmação antes.";
+"subscriptions__allowance_detail_active_allowee" = "Suas solicitações dentro destes limites são pagas automaticamente.";
+"subscriptions__allowance_detail_ended" = "Esta pré-aprovação foi encerrada. Cada solicitação volta a pedir confirmação.";
+"subscriptions__allowance_error_not_linked" = "Este contato ainda não está conectado. Tente novamente em instantes.";
+"subscriptions__allowance_error_unavailable" = "Esta pré-aprovação não está disponível no momento.";
+"subscriptions__allowance_payment_in_progress" = "Esta solicitação já está sendo paga.";
+"subscriptions__allowance_executed_title" = "Pagamento Executado";
+"subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}";
+"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 3ba9289c8..6d1c1666a 100644
--- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
@@ -1560,3 +1560,50 @@
"usdt__deposit_refund_confirmation" = "Pedir o reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Podem aplicar-se taxas de reembolso.";
"usdt__deposit_tron_refund_note" = "Os reembolsos na TRON podem exigir assistência do operador da Orchestra.";
"usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}";
+"subscriptions__allowances" = "Pré-aprovações";
+"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações";
+"subscriptions__allowances_empty_description" = "Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança.";
+"subscriptions__allowance_add" = "Adicionar pré-aprovação";
+"subscriptions__allowance_title" = "Pré-aprovação";
+"subscriptions__allowance_choose_contact" = "Escolher contacto";
+"subscriptions__allowance_no_contacts" = "Adicione primeiro um contacto. As pré-aprovações abrangem pedidos de pagamento dos seus contactos.";
+"subscriptions__allowance_set_title" = "Definir pré-aprovação";
+"subscriptions__allowance_set_explanation" = "Aprovar automaticamente pedidos de pagamento de {name} abaixo destes limites:";
+"subscriptions__allowance_payment_limit" = "Limite por pagamento";
+"subscriptions__allowance_monthly_allowance" = "Valor mensal";
+"subscriptions__allowance_set_summary" = "Os pedidos até {perPayment} serão pagos automaticamente, até {monthly} por mês, sem lhe perguntar sempre.";
+"subscriptions__allowance_save" = "Guardar pré-aprovação";
+"subscriptions__allowance_monthly_limit" = "Limite mensal";
+"subscriptions__allowance_per_payment_short" = "{amount} por pagamento";
+"subscriptions__allowance_per_payment" = "Por pagamento";
+"subscriptions__allowance_each_month" = "Todos os meses";
+"subscriptions__allowance_up_to" = "Até {amount}";
+"subscriptions__allowance_status_active" = "Ativa";
+"subscriptions__allowance_status_waiting" = "A aguardar resposta";
+"subscriptions__allowance_status_needs_answer" = "A aguardar a sua resposta";
+"subscriptions__allowance_status_scheduled" = "Ainda não ativa";
+"subscriptions__allowance_status_expired" = "Expirada";
+"subscriptions__allowance_status_declined" = "Recusada";
+"subscriptions__allowance_status_conflicted" = "Requer atenção";
+"subscriptions__allowance_status_ended" = "Terminada";
+"subscriptions__allowance_paid_automatically" = "{amount} pago automaticamente";
+"subscriptions__allowance_paid_so_far" = "Pago automaticamente";
+"subscriptions__allowance_offer_headline" = "{name} oferece-lhe\numa pré-aprovação";
+"subscriptions__allowance_request_headline" = "{name} pede\numa pré-aprovação";
+"subscriptions__allowance_offer_explanation" = "A carteira de {name} pagará os seus pedidos dentro destes limites sem perguntar sempre. Qualquer um de vós pode terminá-la.";
+"subscriptions__allowance_request_explanation" = "A sua carteira pagará os pedidos de {name} dentro destes limites sem lhe perguntar sempre. Qualquer um de vós pode terminá-la.";
+"subscriptions__allowance_accept" = "Aceitar";
+"subscriptions__allowance_decline" = "Recusar";
+"subscriptions__allowance_swipe_end" = "Deslize para terminar a pré-aprovação";
+"subscriptions__allowance_swipe_withdraw" = "Deslize para retirar";
+"subscriptions__allowance_detail_active_allower" = "Os pedidos dentro destes limites são pagos automaticamente. Para valores acima, ser-lhe-á pedida confirmação primeiro.";
+"subscriptions__allowance_detail_active_allowee" = "Os seus pedidos dentro destes limites são pagos automaticamente.";
+"subscriptions__allowance_detail_ended" = "Esta pré-aprovação terminou. Cada pedido volta a pedir confirmação.";
+"subscriptions__allowance_error_not_linked" = "Este contacto ainda não está ligado. Tente novamente daqui a pouco.";
+"subscriptions__allowance_error_unavailable" = "Esta pré-aprovação não está disponível de momento.";
+"subscriptions__allowance_payment_in_progress" = "Este pedido já está a ser pago.";
+"subscriptions__allowance_executed_title" = "Pagamento executado";
+"subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}";
+"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 a332ce783..c8cc453c7 100644
--- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
@@ -1894,3 +1894,50 @@
"usdt__deposit_refund_confirmation" = "Запросить возврат депозита {amount} USDT?\n\nСеть:\n{network}\n\nАдрес для возврата:\n{address}\n\nУбедитесь, что этот адрес может получать USDT в сети {shortNetwork}. Может взиматься комиссия за возврат.";
"usdt__deposit_tron_refund_note" = "Для возвратов в сети TRON может потребоваться помощь оператора Orchestra.";
"usdt__deposit_refund_address_network" = "Адрес USDT в сети {network}";
+"subscriptions__allowances" = "Лимиты автооплаты";
+"subscriptions__allowances_empty_headline" = "Настройте\nлимиты автооплаты";
+"subscriptions__allowances_empty_description" = "Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов.";
+"subscriptions__allowance_add" = "Добавить Лимит";
+"subscriptions__allowance_title" = "Лимит автооплаты";
+"subscriptions__allowance_choose_contact" = "Выберите Контакт";
+"subscriptions__allowance_no_contacts" = "Сначала добавьте контакт. Лимиты автооплаты действуют для запросов на оплату от ваших контактов.";
+"subscriptions__allowance_set_title" = "Задать Лимит";
+"subscriptions__allowance_set_explanation" = "Автоматически одобрять запросы на оплату от {name} в пределах этих лимитов:";
+"subscriptions__allowance_payment_limit" = "Лимит платежа";
+"subscriptions__allowance_monthly_allowance" = "Месячный лимит автооплаты";
+"subscriptions__allowance_set_summary" = "Запросы до {perPayment} будут оплачиваться автоматически, но не более {monthly} в месяц, без подтверждения каждый раз.";
+"subscriptions__allowance_save" = "Сохранить Лимит";
+"subscriptions__allowance_monthly_limit" = "Месячный лимит";
+"subscriptions__allowance_per_payment_short" = "{amount} за платёж";
+"subscriptions__allowance_per_payment" = "За платёж";
+"subscriptions__allowance_each_month" = "Каждый месяц";
+"subscriptions__allowance_up_to" = "До {amount}";
+"subscriptions__allowance_status_active" = "Активен";
+"subscriptions__allowance_status_waiting" = "Ожидает ответа";
+"subscriptions__allowance_status_needs_answer" = "Ожидает вашего ответа";
+"subscriptions__allowance_status_scheduled" = "Ещё не активен";
+"subscriptions__allowance_status_expired" = "Истёк";
+"subscriptions__allowance_status_declined" = "Отклонён";
+"subscriptions__allowance_status_conflicted" = "Требует внимания";
+"subscriptions__allowance_status_ended" = "Завершён";
+"subscriptions__allowance_paid_automatically" = "{amount} оплачено автоматически";
+"subscriptions__allowance_paid_so_far" = "Оплачено автоматически";
+"subscriptions__allowance_offer_headline" = "{name} предлагает\nлимит автооплаты";
+"subscriptions__allowance_request_headline" = "{name} просит\nлимит автооплаты";
+"subscriptions__allowance_offer_explanation" = "Кошелёк {name} будет оплачивать ваши запросы в пределах этих лимитов без подтверждения каждый раз. Любой из вас может его завершить.";
+"subscriptions__allowance_request_explanation" = "Ваш кошелёк будет оплачивать запросы {name} в пределах этих лимитов, не спрашивая вас каждый раз. Любой из вас может его завершить.";
+"subscriptions__allowance_accept" = "Принять";
+"subscriptions__allowance_decline" = "Отклонить";
+"subscriptions__allowance_swipe_end" = "Проведите для Завершения";
+"subscriptions__allowance_swipe_withdraw" = "Проведите для Отзыва";
+"subscriptions__allowance_detail_active_allower" = "Запросы в пределах этих лимитов оплачиваются автоматически. Всё, что выше, сначала потребует вашего подтверждения.";
+"subscriptions__allowance_detail_active_allowee" = "Ваши запросы в пределах этих лимитов оплачиваются автоматически.";
+"subscriptions__allowance_detail_ended" = "Этот лимит автооплаты завершён. Каждый запрос снова требует подтверждения.";
+"subscriptions__allowance_error_not_linked" = "Этот контакт ещё не подключён. Повторите попытку через минуту.";
+"subscriptions__allowance_error_unavailable" = "Этот лимит автооплаты сейчас недоступен.";
+"subscriptions__allowance_payment_in_progress" = "Этот запрос уже оплачивается.";
+"subscriptions__allowance_executed_title" = "Платёж Выполнен";
+"subscriptions__allowance_executed_description" = "Автооплата отправила {amount} пользователю {name}";
+"subscriptions__allowance_limit_title" = "Лимит Достигнут";
+"subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить.";
+"subscriptions__allowance_auto_paid" = "Оплачено автоматически";
diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift
new file mode 100644
index 000000000..aece2ece2
--- /dev/null
+++ b/Bitkit/Services/PaykitAllowance.swift
@@ -0,0 +1,341 @@
+import Foundation
+import Paykit
+
+/// An Allowance between this wallet's identity and one contact's identity, built from the SDK record.
+/// Eligibility runs on the trusted time passed in by the caller and never on `SubscriptionClock`, so a clock offset cannot move an allowance window.
+struct PaykitAllowance: Identifiable, Hashable {
+ struct ID: Codable, Hashable {
+ let counterparty: String
+ let allowanceId: String
+ }
+
+ enum Role: String, Codable, Hashable {
+ case allower
+ case allowee
+ }
+
+ enum Status: Hashable {
+ /// Sent by this wallet; the other side has not answered yet.
+ case awaitingAnswer
+ /// Received; this wallet must accept or decline.
+ case awaitingMyAnswer
+ case active
+ case notYetActive
+ case expired
+ case declined
+ case ended
+ case conflicted
+ }
+
+ let id: ID
+ let role: Role
+ let lifecycleState: Paykit.AllowanceLifecycleState
+ let isProposedByMe: Bool
+ let perPaymentMaxSats: UInt64?
+ let monthlyLimitSats: UInt64?
+ let monthlyAnchor: Date?
+ let activeFrom: Date?
+ let expiresAt: Date?
+ let allowedPaymentEndpointIdentifiers: [String]?
+ let lastEventAt: Date?
+ /// The proposal's stream item as displayed; acceptance names it so a re-sent proposal asks again.
+ let proposalStreamItemId: UInt64?
+
+ var counterparty: String {
+ id.counterparty
+ }
+
+ var allowanceId: String {
+ id.allowanceId
+ }
+
+ /// Allowance ids are unique only within one link, so local state keys an allowance by its counterparty too.
+ var scopedId: String {
+ Self.scopedId(counterparty: counterparty, allowanceId: allowanceId)
+ }
+
+ static func scopedId(counterparty: String, allowanceId: String) -> String {
+ "\(PubkyPublicKeyFormat.normalized(counterparty) ?? counterparty)/\(allowanceId)"
+ }
+
+ init?(record: Paykit.AllowanceRecord) {
+ guard let localRole = record.localRole,
+ let role = Role(localRole),
+ let terms = record.terms,
+ terms.asset() == PaykitIssuerInterop.bitcoinAsset
+ else { return nil }
+
+ let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) }
+ let perPaymentMaxSats = terms.perPaymentAmount().flatMap { Self.sats(fromBitcoinAmount: $0.maximum()) }
+ let monthlyLimitSats = monthly?.amountLimit().flatMap { Self.sats(fromBitcoinAmount: $0) }
+ let monthlyAnchor = monthly?.period().anchor().flatMap(PaykitAllowanceTime.parse)
+ // This wallet pays only under limits it can show: terms whose caps don't parse to sats with a monthly anchor are dropped.
+ if role == .allower, perPaymentMaxSats == nil || monthlyLimitSats == nil || monthlyAnchor == nil {
+ return nil
+ }
+ id = ID(counterparty: record.counterparty, allowanceId: record.allowanceId)
+ self.role = role
+ lifecycleState = record.state
+ isProposedByMe = record.proposalOutboundMessageId != nil
+ self.perPaymentMaxSats = perPaymentMaxSats
+ self.monthlyLimitSats = monthlyLimitSats
+ self.monthlyAnchor = monthlyAnchor
+ activeFrom = terms.activeFrom().flatMap(PaykitAllowanceTime.parse)
+ expiresAt = terms.expiresAt().flatMap(PaykitAllowanceTime.parse)
+ allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers()
+ lastEventAt = record.lastEventAt.flatMap(PaykitAllowanceTime.parse)
+ proposalStreamItemId = record.proposalStreamItemId
+ }
+
+ init(
+ id: ID,
+ role: Role,
+ lifecycleState: Paykit.AllowanceLifecycleState,
+ isProposedByMe: Bool,
+ perPaymentMaxSats: UInt64?,
+ monthlyLimitSats: UInt64?,
+ monthlyAnchor: Date?,
+ activeFrom: Date? = nil,
+ expiresAt: Date? = nil,
+ allowedPaymentEndpointIdentifiers: [String]? = nil,
+ lastEventAt: Date? = nil,
+ proposalStreamItemId: UInt64? = nil
+ ) {
+ self.id = id
+ self.role = role
+ self.lifecycleState = lifecycleState
+ self.isProposedByMe = isProposedByMe
+ self.perPaymentMaxSats = perPaymentMaxSats
+ self.monthlyLimitSats = monthlyLimitSats
+ self.monthlyAnchor = monthlyAnchor
+ self.activeFrom = activeFrom
+ self.expiresAt = expiresAt
+ self.allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers
+ self.lastEventAt = lastEventAt
+ self.proposalStreamItemId = proposalStreamItemId
+ }
+
+ func status(at now: Date) -> Status {
+ switch lifecycleState {
+ case .proposed:
+ return isProposedByMe ? .awaitingAnswer : .awaitingMyAnswer
+ case .accepted:
+ if let expiresAt, now >= expiresAt {
+ return .expired
+ }
+ if let activeFrom, now < activeFrom {
+ return .notYetActive
+ }
+ return .active
+ case .rejected:
+ return .declined
+ case .ended:
+ return .ended
+ case .conflicted, .unknown:
+ return .conflicted
+ }
+ }
+
+ /// The payer side: this wallet pays the counterparty's requests automatically.
+ var isAllower: Bool {
+ role == .allower
+ }
+
+ var canEnd: Bool {
+ switch lifecycleState {
+ case .accepted: true
+ case .proposed: isProposedByMe
+ default: false
+ }
+ }
+
+ var isAnswerable: Bool {
+ lifecycleState == .proposed && !isProposedByMe
+ }
+
+ static func isMonthly(_ period: Paykit.AllowancePeriod) -> Bool {
+ period.kind() == "anchored" && period.every() == 1 && period.unit() == "month"
+ }
+
+ static func sats(fromBitcoinAmount amount: String) -> UInt64? {
+ if amount.split(separator: ".").allSatisfy({ $0.allSatisfy { $0 == "0" } }) {
+ return 0
+ }
+ return PaykitPaymentRequest.sats(fromBitcoinAmount: amount)
+ }
+}
+
+extension Paykit.AllowanceLifecycleState {
+ var rawDescription: String {
+ switch self {
+ case .proposed: "proposed"
+ case .accepted: "accepted"
+ case .rejected: "rejected"
+ case .ended: "ended"
+ case .conflicted: "conflicted"
+ case .unknown: "unknown"
+ }
+ }
+}
+
+extension PaykitAllowance.Role {
+ init?(_ role: Paykit.AllowanceLocalRole) {
+ switch role {
+ case .allower: self = .allower
+ case .allowee: self = .allowee
+ case .unknown: return nil
+ }
+ }
+}
+
+/// Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms.
+struct PaykitAllowanceLimits: Codable, Hashable {
+ let perPaymentUsd: Decimal
+ let monthlyUsd: Decimal
+ let perPaymentSats: UInt64
+ let monthlySats: UInt64
+
+ static let perPaymentStopsUsd: [Decimal] = [1, 5, 10, 20, 50]
+ static let monthlyStopsUsd: [Decimal] = [10, 50, 100, 200, 500]
+
+ /// Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays.
+ func terms(monthAnchor: Date, allowedPaymentEndpointIdentifiers: [String]) throws -> Paykit.AllowanceTerms {
+ let perPayment = try Paykit.AllowanceAmountRange(
+ minimum: "0",
+ maximum: WalletViewModel.formatBitcoinAmount(sats: perPaymentSats)
+ )
+ let month = try Paykit.AllowancePeriod(
+ kind: "anchored",
+ every: 1,
+ unit: "month",
+ anchor: PaykitAllowanceTime.format(monthAnchor)
+ )
+ let monthly = try Paykit.AllowancePeriodLimit(
+ amountLimit: WalletViewModel.formatBitcoinAmount(sats: monthlySats),
+ paymentCountLimit: nil,
+ period: month
+ )
+ return try Paykit.AllowanceTerms(
+ asset: PaykitIssuerInterop.bitcoinAsset,
+ perPaymentAmount: perPayment,
+ periodLimits: [monthly],
+ lifetimeAmountLimit: nil,
+ activeFrom: nil,
+ expiresAt: nil,
+ allowedPaymentEndpointIdentifiers: allowedPaymentEndpointIdentifiers
+ )
+ }
+}
+
+enum PaykitAllowanceTime {
+ static func format(_ date: Date) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ return formatter.string(from: date)
+ }
+
+ static func parse(_ value: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: value) {
+ return date
+ }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: value)
+ }
+
+ /// First instant of the UTC calendar month that contains `date`.
+ static func monthStart(containing date: Date) -> Date {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let components = calendar.dateComponents([.year, .month], from: date)
+ return calendar.date(from: components) ?? date
+ }
+
+ /// The anchored monthly window `[start, end)` that contains `date`. Anchors on a day that a short month lacks
+ /// clamp to that month's last day, as the spec's anchored-period arithmetic does.
+ static func monthlyWindow(anchor: Date, containing date: Date) -> (start: Date, end: Date) {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let boundary: (Int) -> Date = { calendar.date(byAdding: .month, value: $0, to: anchor) ?? anchor }
+ let anchorMonth = calendar.dateComponents([.year, .month], from: anchor)
+ let dateMonth = calendar.dateComponents([.year, .month], from: date)
+ // Every boundary counts from the original anchor, so a clamped February never shortens later months.
+ var index = ((dateMonth.year ?? 0) - (anchorMonth.year ?? 0)) * 12 + (dateMonth.month ?? 0) - (anchorMonth.month ?? 0)
+ while boundary(index) > date {
+ index -= 1
+ }
+ while boundary(index + 1) <= date {
+ index += 1
+ }
+ return (boundary(index), boundary(index + 1))
+ }
+}
+
+/// Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this
+/// Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow.
+enum PaykitAllowanceCapacity {
+ struct Attempt: Equatable {
+ let counterparty: String
+ let allowanceId: String
+ let amountSats: UInt64
+ let admittedAt: Date
+ let isLive: Bool
+ }
+
+ /// Allowance ids are unique only within one link, so attempts count toward an allowance only under its counterparty.
+ static func usedSats(counterparty: String, allowanceId: String, attempts: [Attempt], anchor: Date, now: Date) -> UInt64 {
+ let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: now)
+ return attempts
+ .filter {
+ $0.allowanceId == allowanceId && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) && $0.isLive &&
+ $0.admittedAt >= window.start && $0.admittedAt < window.end
+ }
+ .reduce(0) { $0 + $1.amountSats }
+ }
+
+ static func fits(amountSats: UInt64, allowance: PaykitAllowance, attempts: [Attempt], now: Date) -> Bool {
+ if let perPaymentMaxSats = allowance.perPaymentMaxSats, amountSats > perPaymentMaxSats {
+ return false
+ }
+ // Terms without both ceilings never pay automatically, whatever the SDK accepts.
+ guard allowance.perPaymentMaxSats != nil, let monthlyLimitSats = allowance.monthlyLimitSats, let anchor = allowance.monthlyAnchor else {
+ return false
+ }
+ let used = usedSats(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId, attempts: attempts, anchor: anchor, now: now)
+ return used + amountSats <= monthlyLimitSats
+ }
+
+ static func attempts(from history: Paykit.AllowanceAccountingHistory) -> [Attempt] {
+ history.occurrences.flatMap { occurrence in
+ occurrence.attempts.map { (counterparty: occurrence.key.request.counterparty, attempt: $0) }
+ }.compactMap { counterparty, attempt in
+ guard attempt.mode == .automatic,
+ let allowanceId = attempt.allowanceId,
+ let admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt),
+ let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value())
+ else { return nil }
+ return Attempt(
+ counterparty: counterparty,
+ allowanceId: allowanceId,
+ amountSats: amountSats,
+ admittedAt: admittedAt,
+ isLive: attempt.status != .failed
+ )
+ }
+ }
+}
+
+extension PaykitPaymentRequest {
+ /// Allowances cover only requests priced in bitcoin, paid over a bitcoin rail. A conversion only prices other rails, and
+ /// the SDK requires a bitcoin rail to pay the bitcoin amount, so it does not keep a request manual.
+ var isAllowancePayable: Bool {
+ amount.asset == .btc
+ }
+
+ /// The requested amount in sats, read only for a request where `isAllowancePayable` holds.
+ var allowanceAmountSats: UInt64 {
+ amount.atomic
+ }
+}
diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift
new file mode 100644
index 000000000..812f41938
--- /dev/null
+++ b/Bitkit/Services/PaykitAllowanceExecutor.swift
@@ -0,0 +1,999 @@
+import Combine
+import Foundation
+import LDKNode
+import Paykit
+
+protocol PaykitAllowanceSdkHandling: Sendable {
+ func linkedPeers() async throws -> [LinkedPeerRecord]
+ func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord]
+ func proposeAllowance(
+ counterparty: String,
+ localRole: Paykit.AllowanceLocalRole,
+ terms: Paykit.AllowanceTerms
+ ) async throws -> Paykit.AllowanceRecord
+ func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord
+ func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord
+ func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord
+ @discardableResult
+ func receivePrivateMessages(counterparty: String) async throws -> Paykit.PrivateStreamIntakeReport
+ @discardableResult
+ func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport
+ func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState?
+ func reconcileAllowanceAccounting(_ reconciliation: Paykit.AllowanceAccountingReconciliation) async throws -> Paykit.AllowanceAccountingState
+ func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate]
+ func acceptPaymentRequestAutomatically(
+ scope: Paykit.PaymentRequestScope,
+ selection: Paykit.AllowanceSelectionInput,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.AllowanceAssociationRecord
+ func reserveAutomaticPayment(
+ occurrence: Paykit.PaymentOccurrence,
+ expectedAssociationRevision: UInt64,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.PaymentAttemptDecision
+ func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit
+ .PaymentAttemptDecision
+ func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision
+ @discardableResult
+ func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord
+ @discardableResult
+ func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord
+}
+
+extension PaykitSdkService: PaykitAllowanceSdkHandling {}
+
+/// Local Allowance state kept per identity: the USD labels of each grant, and the execution journal that restart
+/// recovery reads. The SDK ledger stays authoritative for admission.
+struct PaykitAllowanceLocalState: Codable, Equatable {
+ struct Group: Codable, Equatable {
+ let id: String
+ let counterparty: String
+ let limits: PaykitAllowanceLimits
+ var allowanceIds: [String]
+ let createdAt: Date
+ }
+
+ enum Stage: String, Codable {
+ case prepared
+ case submitted
+ case sending
+ case sent
+ case succeeded
+ case failed
+ case unknown
+
+ var isTerminal: Bool {
+ self == .succeeded || self == .failed
+ }
+ }
+
+ struct JournalEntry: Codable, Equatable {
+ let attemptId: String
+ let isAutomatic: Bool
+ let requestId: PaykitPaymentRequest.ID
+ let allowanceId: String?
+ let amountSats: UInt64
+ let paymentEndpointIdentifier: String
+ var paymentHash: String?
+ var onchainAddress: String?
+ var transactionId: String?
+ var stage: Stage
+ let createdAt: Date
+ /// The payee's payment list this attempt consumed and the one it replaced, so a failure releases it after a restart.
+ var paymentListVersion: UInt64? = nil
+ var previousPaymentListVersion: UInt64? = nil
+ }
+
+ var groups: [Group] = []
+ var journal: [JournalEntry] = []
+ var presentedProposalIds: Set = []
+ var notifiedRequestIds: Set = []
+ var lastTrustedTime: Date?
+
+ /// Allowance ids are unique only within one link, so a group also matches its counterparty.
+ func group(containing allowanceId: String, counterparty: String) -> Group? {
+ groups.first { $0.allowanceIds.contains(allowanceId) && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) }
+ }
+}
+
+protocol PaykitAllowanceStoring: Sendable {
+ func load(identity: String) throws -> PaykitAllowanceLocalState
+ func save(_ state: PaykitAllowanceLocalState, identity: String) throws
+}
+
+struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring {
+ private typealias Stored = [String: PaykitAllowanceLocalState]
+
+ func load(identity: String) throws -> PaykitAllowanceLocalState {
+ try loadAll()[identity] ?? PaykitAllowanceLocalState()
+ }
+
+ func save(_ state: PaykitAllowanceLocalState, identity: String) throws {
+ var all = try loadAll()
+ all[identity] = state
+ try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all))
+ }
+
+ private func loadAll() throws -> Stored {
+ guard let data = try Keychain.load(key: .paykitAllowanceState) else { return [:] }
+ do {
+ return try JSONDecoder().decode(Stored.self, from: data)
+ } catch {
+ Logger.warn("Discarding invalid Paykit allowance state: \(error)", context: "PaykitAllowance")
+ return [:]
+ }
+ }
+}
+
+/// 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
+ 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
+ /// 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.
+ @discardableResult
+ func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool
+ func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async
+ func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws
+ func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws
+ func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws
+ func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws
+ /// Removes the proof only when the submission error proves nothing reached the node; returns whether it did.
+ func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool
+ func cancelProofPreparation(_ request: PaykitPaymentRequest) async
+}
+
+struct PaykitAllowanceLivePayer: PaykitAllowancePaying {
+ func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? {
+ 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 resolvePaymentList(
+ publicKey: String,
+ context: PrivatePaykitPaymentContext,
+ attemptId: UUID,
+ outcome: PrivatePaymentListSendOutcome
+ ) async {
+ do {
+ try await PrivatePaykitService.shared.resolvePrivatePaymentListConsumption(
+ publicKey: publicKey,
+ context: context,
+ attemptId: attemptId,
+ outcome: outcome
+ )
+ } catch {
+ Logger.error("Failed to resolve private Paykit payment list consumption: \(error)", context: "PaykitAllowance")
+ }
+ }
+
+ func claimForExecution(_ request: PaykitPaymentRequest) async throws {
+ try await PaykitPaymentRequestService().claimForPayment(request)
+ }
+
+ func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? {
+ await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey, attemptId: attemptId)
+ }
+
+ func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async {
+ do {
+ try await PrivatePaykitService.shared.releaseConsumedPaymentList(publicKey: publicKey, version: version, previousVersion: previousVersion)
+ } catch {
+ Logger.warn("Failed to release a payment list a failed automatic payment consumed: \(error)", context: "PaykitAllowance")
+ }
+ }
+
+ @discardableResult
+ func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool {
+ let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests)
+ var ids = try store.load(identity: identity)
+ guard ids.insert(request.id).inserted else { return false }
+ try store.save(ids, identity: identity)
+ return true
+ }
+
+ func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async {
+ let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests)
+ do {
+ try store.save(store.load(identity: identity).subtracting([request.id]), identity: identity)
+ } catch {
+ Logger.warn("Failed to discard an allowance acceptance intent: \(error)", context: "PaykitAllowance")
+ }
+ }
+
+ func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws {
+ try await PaykitPaymentRequestService().ensurePaymentAllowed(request)
+ }
+
+ func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws {
+ guard let kind = PaykitPaymentProofKind(paymentEndpointIdentifier: paymentEndpointIdentifier) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ try await PaykitPaymentProofService.shared.prepare(
+ request: request,
+ paymentAppId: paymentAppId,
+ paymentEndpointIdentifier: paymentEndpointIdentifier,
+ kind: kind,
+ allowanceId: allowanceId
+ )
+ }
+
+ func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws {
+ try await PaykitPaymentProofService.shared.associateLightningPayment(request, paymentHash: paymentHash)
+ }
+
+ /// Routing fees fall outside the allowance's limits, so an automatic payment may spend at most 1% of its amount on them.
+ static func routeParameters(amountSats: UInt64) -> RouteParametersConfig {
+ RouteParametersConfig(
+ maxTotalRoutingFeeMsat: amountSats * 10,
+ maxTotalCltvExpiryDelta: 1008,
+ maxPathCount: 10,
+ maxChannelSaturationPowerOfHalf: 2
+ )
+ }
+
+ func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws {
+ _ = try await LightningService.shared.send(
+ bolt11: bolt11,
+ sats: sats,
+ params: Self.routeParameters(amountSats: request.allowanceAmountSats),
+ beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(request.paymentDeadline) }
+ )
+ }
+
+ func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool {
+ await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash, submissionError: submissionError)
+ }
+
+ func cancelProofPreparation(_ request: PaykitPaymentRequest) async {
+ await PaykitPaymentProofService.shared.cancelPreparation(request)
+ }
+}
+
+enum PaykitAllowanceEvent: Equatable {
+ case paidAutomatically(counterparty: String, amountSats: UInt64, paymentId: String)
+ case limitReached(counterparty: String, amountSats: UInt64)
+ case ledgerChanged
+}
+
+enum PaykitAllowanceAutoPayResult: Equatable {
+ /// No accepted Allowance covers the request's link.
+ case notCovered
+ /// An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint).
+ case manual
+ /// The payment was handed to the node; the outcome arrives through the payment events.
+ case started
+ /// The payee has not published a payment list newer than the one last paid; the next refresh tries again.
+ case deferred
+}
+
+enum PaykitAllowanceManualPaymentError: LocalizedError {
+ case alreadyRecorded
+
+ var errorDescription: String? {
+ t("subscriptions__allowance_payment_in_progress")
+ }
+}
+
+/// Runs Allowance admission for incoming requests through the SDK: evaluate, capacity preflight, automatic
+/// Acceptance, reserve, begin, pay, record the outcome. The wallet journals every attempt before handoff so a
+/// restart resolves it from the node instead of paying again.
+actor PaykitAllowanceExecutor {
+ static let shared = PaykitAllowanceExecutor()
+
+ private static let eventSubject = PassthroughSubject()
+
+ nonisolated static var eventPublisher: AnyPublisher {
+ eventSubject.eraseToAnyPublisher()
+ }
+
+ private let sdk: any PaykitAllowanceSdkHandling
+ private let store: any PaykitAllowanceStoring
+ private let payer: any PaykitAllowancePaying
+ private let lightningLookup: any PaykitLightningPaymentProofLookingUp
+ private let now: @Sendable () -> Date
+ private var inFlightRequestIds = Set()
+ private(set) var activeIdentity: String?
+
+ init(
+ sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared,
+ store: any PaykitAllowanceStoring = PaykitAllowanceKeychainStore(),
+ payer: any PaykitAllowancePaying = PaykitAllowanceLivePayer(),
+ lightningLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(),
+ now: @escaping @Sendable () -> Date = { Date() }
+ ) {
+ self.sdk = sdk
+ self.store = store
+ self.payer = payer
+ self.lightningLookup = lightningLookup
+ self.now = now
+ }
+
+ func activate(identity: String?) {
+ activeIdentity = identity
+ }
+
+ // MARK: Local state
+
+ func localState(identity: String) -> PaykitAllowanceLocalState {
+ (try? store.load(identity: identity)) ?? PaykitAllowanceLocalState()
+ }
+
+ func updateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) -> Void) {
+ var state = localState(identity: identity)
+ change(&state)
+ do {
+ try store.save(state, identity: identity)
+ } catch {
+ Logger.warn("Failed to save Paykit allowance state: \(error)", context: "PaykitAllowance")
+ }
+ }
+
+ func isHandling(_ requestId: PaykitPaymentRequest.ID) -> Bool {
+ inFlightRequestIds.contains(requestId)
+ }
+
+ /// Trusted time for eligibility: the real clock, never earlier than the last value given to the SDK,
+ /// because the SDK refuses a watermark that moves backwards.
+ func trustedTime(identity: String) -> String {
+ var date = now()
+ updateLocalState(identity: identity) { state in
+ if let last = state.lastTrustedTime, last > date {
+ date = last
+ }
+ state.lastTrustedTime = date
+ }
+ return PaykitAllowanceTime.format(date)
+ }
+
+ // MARK: Ledger
+
+ /// Brings the SDK ledger to a reconciled state. A wallet with no ledger attests an empty history: it has never
+ /// paid through an Allowance. After a restore, outcomes come from the journal and the node.
+ @discardableResult
+ func ensureReconciled(identity: String) async throws -> Paykit.AllowanceAccountingState {
+ let current = try await sdk.allowanceAccountingState()
+ if let current, !current.requiresReconciliation {
+ return current
+ }
+
+ 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) {
+ outcomes.append(Paykit.PaymentOutcomeReport(attemptId: attempt.attemptId, outcome: outcome))
+ }
+ }
+ let reconciled = try await sdk.reconcileAllowanceAccounting(
+ Paykit.AllowanceAccountingReconciliation(
+ expectedRevision: current?.revision,
+ history: history,
+ outcomes: outcomes,
+ trustedTime: trustedTime(identity: identity)
+ )
+ )
+ Logger.info("Reconciled Paykit allowance accounting with \(outcomes.count) verified outcomes", context: "PaykitAllowance")
+ return reconciled
+ }
+
+ /// Resolves attempts a crash or kill left open. Prepared attempts never got a handoff and are released;
+ /// submitted ones are settled from the node. Nothing is paid again.
+ func recover(identity: String) async {
+ 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 }
+ 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)
+ }
+ 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)
+ }
+ }
+ }
+ } catch {
+ Logger.warn("Paykit allowance recovery failed: \(error)", context: "PaykitAllowance")
+ }
+ }
+
+ /// The SDK's block codes for a request over an amount, count or period limit, as on Android.
+ static let capacityBlockCodes: Set = ["amount_outside_range", "period_amount_limit", "period_count_limit", "lifetime_amount_limit"]
+
+ static func isCapacityBlock(_ reason: Paykit.AllowanceAccountingBlock) -> Bool {
+ if case let .sharedRule(code) = reason {
+ return capacityBlockCodes.contains(code)
+ }
+ return false
+ }
+
+ /// Whether a covered request is over every limit of the contact's accepted allowances, decided without the node.
+ func exceedsLimits(_ request: PaykitPaymentRequest, allowances: [PaykitAllowance], identity: String) async -> Bool {
+ let covering = allowances.filter {
+ $0.isAllower && $0.status(at: now()) == .active && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty)
+ }
+ guard !covering.isEmpty else { return false }
+ let attempts = await automaticAttempts()
+ guard !covering.contains(where: {
+ PaykitAllowanceCapacity.fits(amountSats: request.allowanceAmountSats, allowance: $0, attempts: attempts, now: now())
+ }) else { return false }
+ notifyLimitReached(request, identity: identity)
+ return true
+ }
+
+ private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async -> Paykit.PaymentOutcome? {
+ switch attempt.status {
+ case .prepared:
+ return .failed
+ case .succeeded, .failed:
+ return nil
+ case .submitted, .unknown:
+ break
+ }
+
+ guard let entry = localState(identity: identity).journal.first(where: { $0.attemptId == attempt.attemptId }) else {
+ return nil
+ }
+ switch entry.stage {
+ case .prepared, .submitted:
+ // The journal is written before the node call, so no payment left this wallet.
+ return .failed
+ case .succeeded:
+ return .succeeded
+ case .failed:
+ return .failed
+ case .sending, .sent, .unknown:
+ break
+ }
+
+ if let paymentHash = entry.paymentHash {
+ switch await lightningLookup.status(paymentHash: paymentHash) {
+ case .succeeded:
+ return .succeeded
+ case .failed:
+ return .failed
+ case .pending, .unknown:
+ return nil
+ }
+ }
+ return entry.transactionId == nil ? nil : .succeeded
+ }
+
+ 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)
+ if outcome == .failed {
+ await releaseConsumedPaymentList(of: entry)
+ }
+ }
+
+ /// A failed automatic payment leaves the payee's list usable for the manual payment that follows.
+ private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?) async {
+ guard let entry, entry.isAutomatic, let version = entry.paymentListVersion else { return }
+ await payer.releaseConsumedPaymentList(
+ publicKey: entry.requestId.counterparty,
+ version: version,
+ previousVersion: entry.previousPaymentListVersion
+ )
+ }
+
+ /// 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.
+ @discardableResult
+ private func applyJournalOutcome(
+ attemptId: String,
+ outcome: Paykit.PaymentOutcome,
+ identity: String,
+ onlyIfOpen: Bool = false
+ ) -> 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
+ }
+ }
+ Self.eventSubject.send(.ledgerChanged)
+ if let entry = settledEntry, entry.isAutomatic, let paymentHash = entry.paymentHash {
+ Self.eventSubject.send(
+ .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased())
+ )
+ }
+ return entry
+ }
+
+ func automaticAttempts() async -> [PaykitAllowanceCapacity.Attempt] {
+ guard let state = try? await sdk.allowanceAccountingState() else { return [] }
+ return PaykitAllowanceCapacity.attempts(from: state.history)
+ }
+
+ func succeededAutomaticPayments(identity: String) -> [PaykitAllowanceLocalState.JournalEntry] {
+ localState(identity: identity).journal.filter { $0.isAutomatic && $0.stage == .succeeded }
+ }
+
+ // MARK: Automatic payment
+
+ func autoPay(
+ _ request: PaykitPaymentRequest,
+ allowances: [PaykitAllowance],
+ identity: String
+ ) async -> PaykitAllowanceAutoPayResult {
+ guard request.direction == .incoming,
+ request.billingPeriod == nil,
+ request.isAllowancePayable,
+ request.lifecycleState == .proposed,
+ !inFlightRequestIds.contains(request.id),
+ allowances.contains(where: {
+ $0.isAllower && $0.lifecycleState == .accepted &&
+ PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty)
+ })
+ else { return .notCovered }
+
+ inFlightRequestIds.insert(request.id)
+ defer { inFlightRequestIds.remove(request.id) }
+
+ do {
+ try await ensureReconciled(identity: identity)
+ // An unstructured task survives a cancelled caller: stopping after the acceptance would leave the request unpaid.
+ let payment = Task { try await admitAndPay(request, allowances: allowances, identity: identity) }
+ return try await payment.value
+ } catch {
+ Logger.warn("Automatic allowance payment stayed manual: \(error)", context: "PaykitAllowance")
+ return .manual
+ }
+ }
+
+ private func admitAndPay(
+ _ request: PaykitPaymentRequest,
+ allowances: [PaykitAllowance],
+ identity: String
+ ) async throws -> PaykitAllowanceAutoPayResult {
+ let scope = Paykit.PaymentRequestScope(
+ counterparty: request.counterparty,
+ paymentRequestId: request.paymentRequestId
+ )
+ let selectionTime = trustedTime(identity: identity)
+ let candidates = try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: selectionTime)
+ guard let candidate = candidates.first(where: { $0.blocked == nil }),
+ let allowance = allowances.first(where: {
+ $0.allowanceId == candidate.allowanceId && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty)
+ })
+ else {
+ let reasons = candidates.compactMap { $0.blocked.map { String(describing: $0) } }
+ Logger.info("No eligible allowance for an incoming request: \(reasons)", context: "PaykitAllowance")
+ // The SDK blocks a request above the per-payment limit before the capacity check below can notify.
+ if allowances.contains(where: {
+ $0.isAllower && $0.lifecycleState == .accepted && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) &&
+ $0.perPaymentMaxSats.map { request.allowanceAmountSats > $0 } == true
+ }) {
+ notifyLimitReached(request, identity: identity)
+ }
+ return .manual
+ }
+
+ let attempts = await automaticAttempts()
+ guard PaykitAllowanceCapacity.fits(amountSats: request.allowanceAmountSats, allowance: allowance, attempts: attempts, now: now()) else {
+ Logger.info("Allowance monthly limit reached; the request stays on the manual flow", context: "PaykitAllowance")
+ notifyLimitReached(request, identity: identity)
+ return .manual
+ }
+
+ let resolvedPayment: PrivatePaykitAllowancePayment?
+ do {
+ resolvedPayment = try await payer.resolve(request, eligibleIdentifiers: candidate.eligiblePaymentEndpointIdentifiers)
+ } catch PaykitAllowanceError.paymentListPending {
+ Logger.info("Deferred an incoming request until the payee publishes a new payment list", context: "PaykitAllowance")
+ return .deferred
+ }
+ guard let payment = resolvedPayment else {
+ Logger.info("No payable private endpoint for an allowance payment; leaving it manual", context: "PaykitAllowance")
+ return .manual
+ }
+ // An on-chain miner fee falls outside the allowance's limits, so only Lightning pays automatically.
+ guard let paymentHash = payment.lightningPaymentHash else {
+ Logger.info("An on-chain allowance payment stays manual", context: "PaykitAllowance")
+ return .manual
+ }
+
+ let endpointIdentifier = payment.endpoint.methodId.rawValue
+ let paymentAppId = try payment.context.paymentAppId(for: endpointIdentifier)
+ // The SDK requires this app to hold the shared execution claim before it queues an automatic Acceptance.
+ try await payer.claimForExecution(request)
+ let recordedAcceptanceIntent = try await payer.recordAcceptanceIntent(request, identity: identity)
+ let association: Paykit.AllowanceAssociationRecord
+ do {
+ association = try await sdk.acceptPaymentRequestAutomatically(
+ scope: scope,
+ selection: Paykit.AllowanceSelectionInput(allowanceId: candidate.allowanceId, expectedRevision: nil, trustedTime: selectionTime),
+ checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: selectionTime)
+ )
+ } catch {
+ // An interrupted response may follow a committed acceptance, so only a definite refusal drops the intent.
+ switch error {
+ case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity:
+ break
+ default:
+ if recordedAcceptanceIntent {
+ await payer.discardAcceptanceIntent(request, identity: identity)
+ }
+ }
+ throw error
+ }
+ _ = try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty)
+
+ let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil)
+ let reservation = try await sdk.reserveAutomaticPayment(
+ occurrence: occurrence,
+ expectedAssociationRevision: association.revisions.last?.revision ?? 1,
+ checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity))
+ )
+ guard case let .ready(prepared) = reservation else {
+ var isCapacityBlock = false
+ if case let .blocked(reason) = reservation {
+ Logger.info("Allowance reservation blocked: \(reason)", context: "PaykitAllowance")
+ isCapacityBlock = Self.isCapacityBlock(reason)
+ }
+ _ = try? await sdk.markPaymentManualOnly(occurrence: occurrence)
+ // Only a limit says Limit Reached; an ended allowance or a manual-only decision just asks.
+ if isCapacityBlock {
+ notifyLimitReached(request, identity: identity)
+ }
+ return .manual
+ }
+
+ journal(
+ PaykitAllowanceLocalState.JournalEntry(
+ attemptId: prepared.attemptId,
+ isAutomatic: true,
+ requestId: request.id,
+ allowanceId: prepared.allowanceId,
+ amountSats: request.allowanceAmountSats,
+ paymentEndpointIdentifier: endpointIdentifier,
+ paymentHash: payment.lightningPaymentHash,
+ onchainAddress: nil,
+ transactionId: nil,
+ stage: .prepared,
+ createdAt: now()
+ ),
+ identity: identity
+ )
+
+ // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff.
+ _ = try? await sdk.receivePrivateMessages(counterparty: request.counterparty)
+ let handoff: Paykit.PaymentAttemptDecision
+ do {
+ handoff = try await sdk.beginPaymentExecution(
+ attemptId: prepared.attemptId,
+ checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity))
+ )
+ } catch {
+ // Nothing left the wallet, so release the reservation before the request goes back to manual payment.
+ try? await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity)
+ throw error
+ }
+ guard case let .ready(submitted) = handoff else {
+ try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity)
+ return .manual
+ }
+ setStage(.submitted, attemptId: submitted.attemptId, identity: identity)
+
+ 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
+ state.journal[index].previousPaymentListVersion = previousVersion
+ }
+ }
+ try await payer.ensurePaymentAllowed(request)
+ try await payer.prepareProof(
+ request,
+ paymentAppId: paymentAppId,
+ paymentEndpointIdentifier: endpointIdentifier,
+ allowanceId: submitted.allowanceId
+ )
+ } catch {
+ await payer.cancelProofPreparation(request)
+ await releasePaymentList(
+ request, payment: payment, attemptId: listAttemptId, journalAttemptId: submitted.attemptId, identity: identity,
+ outcome: .definitePreBroadcastFailure
+ )
+ try await record(attemptId: submitted.attemptId, outcome: .failed, identity: identity)
+ throw error
+ }
+
+ return try await payLightning(
+ request,
+ payment: payment,
+ paymentHash: paymentHash,
+ attemptId: submitted.attemptId,
+ listAttemptId: listAttemptId,
+ identity: identity
+ )
+ }
+
+ private func releasePaymentList(
+ _ 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)
+ }
+
+ private func payLightning(
+ _ request: PaykitPaymentRequest,
+ payment: PrivatePaykitAllowancePayment,
+ paymentHash: String,
+ attemptId: String,
+ listAttemptId: UUID,
+ identity: String
+ ) async throws -> PaykitAllowanceAutoPayResult {
+ do {
+ try setStageDurably(.sending, attemptId: attemptId, identity: identity)
+ 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
+ )
+ try await record(attemptId: attemptId, outcome: .failed, identity: identity)
+ throw error
+ }
+
+ do {
+ try await payer.payLightning(
+ request,
+ bolt11: payment.endpoint.value,
+ sats: payment.lightningInvoiceHasAmount ? nil : request.allowanceAmountSats
+ )
+ } catch {
+ 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
+ )
+ 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
+ )
+ try await record(attemptId: attemptId, outcome: .failed, identity: identity)
+ throw error
+ }
+ await releasePaymentList(
+ request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain
+ )
+ setStage(.sent, attemptId: attemptId, identity: identity)
+ Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance")
+ return .started
+ }
+
+ /// Called from the node's payment events for every outbound Lightning payment; only journaled ones are Allowance work.
+ func lightningPaymentSettled(paymentHash: String, succeeded: Bool) async {
+ guard let identity = activeIdentity else { return }
+ let entries = localState(identity: identity).journal.filter {
+ $0.paymentHash?.caseInsensitiveCompare(paymentHash) == .orderedSame &&
+ [.sending, .sent, .unknown, .submitted].contains($0.stage)
+ }
+ for entry in entries {
+ do {
+ try await record(attemptId: entry.attemptId, outcome: succeeded ? .succeeded : .failed, identity: identity)
+ } catch {
+ Logger.warn("Failed to record an allowance payment outcome: \(error)", context: "PaykitAllowance")
+ }
+ }
+ }
+
+ // MARK: Manual payments
+
+ /// Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet.
+ /// Throws `alreadyRecorded` when another live attempt exists for the request, so the same request is never paid twice.
+ func beginManualPayment(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String) async throws -> String? {
+ // The ledger counts bitcoin only, so a payment made over another rail stays out of it.
+ guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming,
+ request.isAllowancePayable, PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier).map({ $0 != .usdtArbitrum }) == true
+ else { return nil }
+ do {
+ try await ensureReconciled(identity: identity)
+ let scope = Paykit.PaymentRequestScope(
+ counterparty: request.counterparty,
+ paymentRequestId: request.paymentRequestId
+ )
+ let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil)
+ let decision = try await sdk.reserveManualPayment(
+ occurrence: occurrence,
+ checks: checks(request, endpointIdentifier: paymentEndpointIdentifier, trustedTime: trustedTime(identity: identity))
+ )
+ guard case let .ready(prepared) = decision else {
+ if case .blocked(.paymentAlreadyRecorded) = decision {
+ throw PaykitAllowanceManualPaymentError.alreadyRecorded
+ }
+ Logger.info("Manual payment not reported to the allowance ledger: \(decision)", context: "PaykitAllowance")
+ return nil
+ }
+ journal(
+ PaykitAllowanceLocalState.JournalEntry(
+ attemptId: prepared.attemptId,
+ isAutomatic: false,
+ requestId: request.id,
+ allowanceId: nil,
+ amountSats: request.allowanceAmountSats,
+ paymentEndpointIdentifier: paymentEndpointIdentifier,
+ paymentHash: nil,
+ onchainAddress: nil,
+ transactionId: nil,
+ stage: .prepared,
+ createdAt: now()
+ ),
+ identity: identity
+ )
+ let handoff = try await sdk.beginPaymentExecution(
+ attemptId: prepared.attemptId,
+ checks: checks(request, endpointIdentifier: paymentEndpointIdentifier, trustedTime: trustedTime(identity: identity))
+ )
+ guard case .ready = handoff else {
+ try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity)
+ return nil
+ }
+ setStage(.submitted, attemptId: prepared.attemptId, identity: identity)
+ Logger.info("Reported a manual payment to the allowance ledger", context: "PaykitAllowance")
+ return prepared.attemptId
+ } catch let error as PaykitAllowanceManualPaymentError {
+ throw error
+ } catch {
+ Logger.warn("Manual payment not reported to the allowance ledger: \(error)", context: "PaykitAllowance")
+ return nil
+ }
+ }
+
+ func manualLightningPaymentSent(attemptId: String, paymentHash: String) {
+ guard let identity = activeIdentity else { return }
+ updateLocalState(identity: identity) { state in
+ guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return }
+ state.journal[index].paymentHash = paymentHash.lowercased()
+ state.journal[index].stage = .sent
+ }
+ }
+
+ /// Called right before a manual on-chain broadcast, so recovery after a kill reads the attempt as unknown, not failed.
+ func manualOnchainPaymentSending(attemptId: String) {
+ guard let identity = activeIdentity else { return }
+ setStage(.sending, attemptId: attemptId, identity: identity)
+ }
+
+ func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async {
+ guard let identity = activeIdentity else { return }
+ if let transactionId {
+ updateLocalState(identity: identity) { state in
+ guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return }
+ state.journal[index].transactionId = transactionId
+ }
+ }
+ do {
+ try await record(attemptId: attemptId, outcome: outcome, identity: identity)
+ } catch {
+ Logger.warn("Failed to record a manual payment outcome: \(error)", context: "PaykitAllowance")
+ }
+ }
+
+ // MARK: Helpers
+
+ private func checks(_ request: PaykitPaymentRequest, endpointIdentifier: String, trustedTime: String) throws -> Paykit.PaymentExecutionChecks {
+ // The ledger counts bitcoin only; a USD or USDT amount must never reach it as sats.
+ guard request.isAllowancePayable else { throw PaykitAllowanceError.unavailable }
+ return try Paykit.PaymentExecutionChecks(
+ trustedTime: trustedTime,
+ paymentEndpointIdentifier: endpointIdentifier,
+ actualAmount: Paykit.AccountingAmount(value: request.amountValue, asset: PaykitIssuerInterop.bitcoinAsset),
+ endpointCurrent: true,
+ localEnabled: true,
+ recurrenceEligible: true
+ )
+ }
+
+ private func journal(_ entry: PaykitAllowanceLocalState.JournalEntry, identity: String) {
+ updateLocalState(identity: identity) { state in
+ state.journal.removeAll { $0.attemptId == entry.attemptId }
+ state.journal.append(entry)
+ if state.journal.count > 200 {
+ state.journal.removeFirst(state.journal.count - 200)
+ }
+ }
+ }
+
+ /// Recovery reads an entry still at `.submitted` as never sent, so the send must not start before `.sending` is saved.
+ private func setStageDurably(_ stage: PaykitAllowanceLocalState.Stage, attemptId: String, identity: String) throws {
+ var state = try store.load(identity: identity)
+ guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else {
+ throw PaykitAllowanceError.unavailable
+ }
+ state.journal[index].stage = stage
+ try store.save(state, identity: identity)
+ }
+
+ /// Settlement can record an outcome while a send is still cleaning up, so a later stage never replaces it.
+ private func setStage(_ stage: PaykitAllowanceLocalState.Stage, attemptId: String, identity: String) {
+ updateLocalState(identity: identity) { state in
+ guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }),
+ !state.journal[index].stage.isTerminal
+ else { return }
+ state.journal[index].stage = stage
+ }
+ }
+
+ private func hasTerminalStage(attemptId: String, identity: String) -> Bool {
+ localState(identity: identity).journal.first { $0.attemptId == attemptId }?.stage.isTerminal == true
+ }
+
+ private func notifyLimitReached(_ request: PaykitPaymentRequest, identity: String) {
+ var isNew = false
+ updateLocalState(identity: identity) { state in
+ isNew = state.notifiedRequestIds.insert(request.paymentRequestId).inserted
+ }
+ if isNew {
+ Self.eventSubject.send(.limitReached(counterparty: request.counterparty, amountSats: request.allowanceAmountSats))
+ }
+ }
+}
diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift
new file mode 100644
index 000000000..e920538d8
--- /dev/null
+++ b/Bitkit/Services/PaykitAllowanceManager.swift
@@ -0,0 +1,385 @@
+import Foundation
+import Observation
+import Paykit
+import UserNotifications
+
+/// One grant as the user sees it. An SDK Allowance binds this wallet's identity to the contact's identity and covers
+/// every Paykit app they use, so a grant is one SDK Allowance. The row keeps the USD limits picked when it was made.
+struct PaykitAllowanceEntry: Identifiable, Hashable {
+ let id: String
+ let allowances: [PaykitAllowance]
+ let limits: PaykitAllowanceLimits?
+
+ var primary: PaykitAllowance {
+ allowances[0]
+ }
+
+ var counterparty: String {
+ primary.counterparty
+ }
+
+ var role: PaykitAllowance.Role {
+ primary.role
+ }
+
+ var perPaymentMaxSats: UInt64? {
+ primary.perPaymentMaxSats
+ }
+
+ var monthlyLimitSats: UInt64? {
+ primary.monthlyLimitSats
+ }
+
+ var canEnd: Bool {
+ allowances.contains(where: \.canEnd)
+ }
+
+ func status(at now: Date) -> PaykitAllowance.Status {
+ primary.status(at: now)
+ }
+}
+
+enum PaykitAllowanceError: LocalizedError {
+ case contactNotLinked
+ case unavailable
+ case paymentListPending
+
+ var errorDescription: String? {
+ switch self {
+ case .contactNotLinked: t("subscriptions__allowance_error_not_linked")
+ case .unavailable, .paymentListPending: t("subscriptions__allowance_error_unavailable")
+ }
+ }
+}
+
+@Observable
+@MainActor
+final class PaykitAllowanceManager {
+ private(set) var allowances: [PaykitAllowance] = []
+ private(set) var localState = PaykitAllowanceLocalState()
+ private(set) var isWorking = false
+ private(set) var autoPaidRequestIds: Set = []
+ /// Keyed by `PaykitAllowance.scopedId`, since another contact can reuse an allowance id.
+ private(set) var autoPaidSatsByScopedId: [String: UInt64] = [:]
+
+ @ObservationIgnored private let sdk: any PaykitAllowanceSdkHandling
+ @ObservationIgnored private let executor: PaykitAllowanceExecutor
+ @ObservationIgnored private let now: () -> Date
+ @ObservationIgnored private let canPayNow: @MainActor () -> Bool
+ @ObservationIgnored private var identity: String?
+ @ObservationIgnored private var isProcessingRequests = false
+ private var updateDepth = 0
+
+ /// Whether allowances are being refreshed or a pass is deciding requests, so a covered request may still change hands.
+ var isUpdating: Bool {
+ updateDepth > 0
+ }
+
+ @ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:]
+
+ init(
+ sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared,
+ executor: PaykitAllowanceExecutor = .shared,
+ now: @escaping () -> Date = { Date() },
+ canPayNow: @escaping @MainActor () -> Bool = PaykitAllowanceManager.lightningReady
+ ) {
+ self.sdk = sdk
+ self.executor = executor
+ self.now = now
+ self.canPayNow = canPayNow
+ }
+
+ /// A node that just started lists its channels before they reconnect, and a payment sent then finds no route.
+ static func lightningReady() -> Bool {
+ guard LightningService.shared.status?.isRunning == true,
+ let channels = LightningService.shared.channels
+ else { return false }
+ return channels.isEmpty || channels.contains(where: \.isUsable)
+ }
+
+ var entries: [PaykitAllowanceEntry] {
+ var grouped: [String: [PaykitAllowance]] = [:]
+ var order: [String] = []
+ for allowance in allowances {
+ let key = localState.group(containing: allowance.allowanceId, counterparty: allowance.counterparty)?.id
+ ?? allowance.scopedId
+ if grouped[key] == nil {
+ order.append(key)
+ }
+ grouped[key, default: []].append(allowance)
+ }
+ return order.compactMap { key in
+ guard let allowances = grouped[key], !allowances.isEmpty else { return nil }
+ let limits = localState.groups.first { $0.id == key }?.limits
+ return PaykitAllowanceEntry(id: key, allowances: allowances, limits: limits)
+ }
+ }
+
+ func entry(id: String) -> PaykitAllowanceEntry? {
+ entries.first { $0.id == id }
+ }
+
+ func activate(identity: String?) async {
+ guard let identity else {
+ deactivate()
+ return
+ }
+ let identityChanged = self.identity != identity
+ self.identity = identity
+ await executor.activate(identity: identity)
+ if identityChanged {
+ manualRequestIds = [:]
+ await executor.recover(identity: identity)
+ }
+ await refresh()
+ }
+
+ func deactivate() {
+ identity = nil
+ Task { await executor.activate(identity: nil) }
+ allowances = []
+ localState = PaykitAllowanceLocalState()
+ autoPaidRequestIds = []
+ autoPaidSatsByScopedId = [:]
+ manualRequestIds = [:]
+ }
+
+ func refresh() async {
+ guard let identity else { return }
+ updateDepth += 1
+ defer { updateDepth -= 1 }
+ do {
+ let records = try await sdk.listAllowances(
+ filter: Paykit.AllowanceFilter(counterparty: nil, localRole: nil, states: [])
+ )
+ allowances = records
+ .filter { $0.historyStatus == .consistent || $0.historyStatus == .unresolvedReferences }
+ .compactMap(PaykitAllowance.init(record:))
+ } catch {
+ Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance")
+ }
+ localState = await executor.localState(identity: identity)
+ let paid = await executor.succeededAutomaticPayments(identity: identity)
+ 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
+ }
+ }
+
+ func autoPaidSats(for entry: PaykitAllowanceEntry) -> UInt64 {
+ entry.allowances.reduce(0) { $0 + (autoPaidSatsByScopedId[$1.scopedId] ?? 0) }
+ }
+
+ /// 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 }
+ return allowances.contains { allowance in
+ guard allowance.isAllower, allowance.status(at: now()) == .active,
+ PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty), let acceptedAt = allowance.lastEventAt,
+ let createdAt = request.createdAt else { return false }
+ return createdAt >= acceptedAt.addingTimeInterval(-Self.acceptanceClockTolerance)
+ }
+ }
+
+ private var allowancesSignature: Int {
+ var hasher = Hasher()
+ for allowance in allowances {
+ hasher.combine(allowance.allowanceId)
+ hasher.combine(allowance.lifecycleState.rawDescription)
+ }
+ hasher.combine(autoPaidSatsByScopedId.values.reduce(0, +))
+ return hasher.finalize()
+ }
+
+ // MARK: Lifecycle
+
+ func propose(to contact: PubkyContact, limits: PaykitAllowanceLimits) async throws {
+ guard let identity else { throw PaykitAllowanceError.unavailable }
+ isWorking = true
+ defer { isWorking = false }
+
+ let isLinked = try await sdk.linkedPeers().contains {
+ PubkyPublicKeyFormat.matches($0.counterparty, contact.publicKey) && $0.state == .linked
+ }
+ guard isLinked else { throw PaykitAllowanceError.contactNotLinked }
+
+ let terms = try limits.terms(
+ monthAnchor: PaykitAllowanceTime.monthStart(containing: now()),
+ allowedPaymentEndpointIdentifiers: Self.allowedPaymentEndpointIdentifiers
+ )
+ let record = try await sdk.proposeAllowance(counterparty: contact.publicKey, localRole: .allower, terms: terms)
+ _ = try? await sdk.processOutboundPrivateMessages(counterparty: contact.publicKey)
+
+ let group = PaykitAllowanceLocalState.Group(
+ id: UUID().uuidString.lowercased(),
+ counterparty: contact.publicKey,
+ limits: limits,
+ allowanceIds: [record.allowanceId],
+ createdAt: now()
+ )
+ await executor.updateLocalState(identity: identity) { $0.groups.append(group) }
+ Logger.info("Proposed an allowance", context: "PaykitAllowance")
+ await refresh()
+ }
+
+ func accept(_ entry: PaykitAllowanceEntry) async throws {
+ try await respond(to: entry) { allowance in
+ guard let proposalStreamItemId = allowance.proposalStreamItemId else { throw PaykitAllowanceError.unavailable }
+ return try await self.sdk.acceptAllowance(
+ counterparty: allowance.counterparty,
+ allowanceId: allowance.allowanceId,
+ expectedProposalStreamItemId: proposalStreamItemId
+ )
+ }
+ }
+
+ func reject(_ entry: PaykitAllowanceEntry) async throws {
+ try await respond(to: entry) { allowance in
+ try await self.sdk.rejectAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId)
+ }
+ }
+
+ func end(_ entry: PaykitAllowanceEntry) async throws {
+ isWorking = true
+ defer { isWorking = false }
+ var endedAny = false
+ for allowance in entry.allowances where allowance.canEnd {
+ _ = try await sdk.endAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId)
+ endedAny = true
+ _ = try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty)
+ }
+ guard endedAny else { throw PaykitAllowanceError.unavailable }
+ await refresh()
+ }
+
+ private func respond(to entry: PaykitAllowanceEntry, _ response: (PaykitAllowance) async throws -> Paykit.AllowanceRecord) async throws {
+ isWorking = true
+ defer { isWorking = false }
+ var respondedAny = false
+ for allowance in entry.allowances where allowance.isAnswerable {
+ _ = try await response(allowance)
+ respondedAny = true
+ _ = try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty)
+ }
+ guard respondedAny else { throw PaykitAllowanceError.unavailable }
+ if let identity {
+ let ids = entry.allowances.map(\.scopedId)
+ await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) }
+ }
+ await refresh()
+ }
+
+ // MARK: Presentation
+
+ func proposalForPresentation() -> PaykitAllowanceEntry? {
+ entries.first { entry in
+ entry.allowances.contains(where: \.isAnswerable) &&
+ !entry.allowances.contains { localState.presentedProposalIds.contains($0.scopedId) }
+ }
+ }
+
+ func markProposalPresented(_ entry: PaykitAllowanceEntry) async {
+ guard let identity else { return }
+ let ids = entry.allowances.map(\.scopedId)
+ await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) }
+ localState.presentedProposalIds.formUnion(ids)
+ }
+
+ // MARK: Automatic payments
+
+ /// Pays incoming requests that an active Allowance covers. Returns whether any request was handled or handed back
+ /// for manual payment, so the caller refreshes before presenting the rest.
+ func processIncomingRequests(_ requests: [PaykitPaymentRequest]) async -> Bool {
+ guard let identity, !isProcessingRequests else { return false }
+ updateDepth += 1
+ defer { updateDepth -= 1 }
+ let signature = allowancesSignature
+ let covered = requests.filter(isAwaitingAutomaticPayment)
+ guard !covered.isEmpty else { return false }
+ guard canPayNow() else {
+ // A request over the limits needs the user anyway, so it is handed back before waiting for a channel.
+ var handedBack = false
+ for request in covered where await executor.exceedsLimits(request, allowances: allowances, identity: identity) {
+ manualRequestIds[request.id] = signature
+ handedBack = true
+ }
+ Logger.info("Holding covered requests until the node has a usable channel", context: "PaykitAllowance")
+ return handedBack
+ }
+
+ isProcessingRequests = true
+ defer { isProcessingRequests = false }
+ var handledAny = false
+ for request in covered {
+ let result = await executor.autoPay(request, allowances: allowances, identity: identity)
+ Logger.info("Automatic payment pass for a covered request ended as \(result)", context: "PaykitAllowance")
+ switch result {
+ case .started:
+ handledAny = true
+ case .manual, .notCovered:
+ manualRequestIds[request.id] = signature
+ case .deferred:
+ break
+ }
+ }
+ if handledAny {
+ await refresh()
+ }
+ return handledAny
+ }
+
+ /// Whether the allowance flow owns the request, so no sheet may open for it. A covered request belongs to the flow
+ /// from the moment it arrives, even before the first pass over it, until that pass finds it manual.
+ func isAutomaticallyHandling(_ request: PaykitPaymentRequest) async -> Bool {
+ if isAwaitingAutomaticPayment(request) {
+ return true
+ }
+ return await executor.isHandling(request.id)
+ }
+
+ /// Whether an allowance this wallet grants, or was asked to grant, could start covering the request's contact.
+ func mayCover(_ request: PaykitPaymentRequest) -> Bool {
+ allowances.contains {
+ $0.isAllower && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) &&
+ ($0.lifecycleState == .proposed || $0.status(at: now()) == .active)
+ }
+ }
+
+ private func isAwaitingAutomaticPayment(_ request: PaykitPaymentRequest) -> Bool {
+ request.requiresAcceptance && request.billingPeriod == nil && coversRequest(request) && manualRequestIds[request.id] != allowancesSignature
+ }
+
+ static let acceptanceClockTolerance: TimeInterval = 30
+
+ static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers(
+ PublicPaykitService.MethodId.publishableMethodIds.filter { $0 != .usdtArbitrum }.map(\.rawValue),
+ network: Env.network
+ )
+}
+
+/// Allowance outcomes reach the user as a notification banner when notifications are allowed, or as a toast.
+enum PaykitAllowanceNotifier {
+ @MainActor
+ static func post(title: String, body: String, fallback: @escaping @MainActor () -> Void) {
+ Task {
+ let center = UNUserNotificationCenter.current()
+ let settings = await center.notificationSettings()
+ guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
+ fallback()
+ return
+ }
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.sound = .default
+ content.userInfo = ["bitkit_action": "paykit_allowance"]
+ do {
+ try await center.add(UNNotificationRequest(identifier: "paykit-allowance-\(UUID().uuidString)", content: content, trigger: nil))
+ } catch {
+ fallback()
+ }
+ }
+ }
+}
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index ec8410d2b..2323ef787 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -51,6 +51,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable {
let paymentEndpointIdentifier: String
let kind: PaykitPaymentProofKind
let billingPeriod: PaykitBillingPeriod?
+ /// Set only for an automatic Allowance payment, from its succeeded attempt. Manual payments omit it.
+ var allowanceId: String?
var paymentStarted: Bool
var paymentIdentifier: String?
var proofData: String?
@@ -102,6 +104,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable {
paymentEndpointIdentifier: String,
kind: PaykitPaymentProofKind,
billingPeriod: PaykitBillingPeriod? = nil,
+ allowanceId: String? = nil,
paymentStarted: Bool = false,
paymentIdentifier: String?,
proofData: String?,
@@ -119,6 +122,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable {
self.paymentEndpointIdentifier = paymentEndpointIdentifier
self.kind = kind
self.billingPeriod = billingPeriod
+ self.allowanceId = allowanceId
self.paymentStarted = paymentStarted
self.paymentIdentifier = paymentIdentifier
self.proofData = proofData
@@ -345,7 +349,8 @@ actor PaykitPaymentProofService {
paymentAppId: String,
paymentEndpointIdentifier: String,
kind: PaykitPaymentProofKind,
- paymentTerms: PaykitRequestPricing.Payment? = nil
+ paymentTerms: PaykitRequestPricing.Payment? = nil,
+ allowanceId: String? = nil
) async throws {
if try await attemptService.hasAttempt(for: request.id) {
if kind == .onchain,
@@ -372,6 +377,7 @@ actor PaykitPaymentProofService {
if kind == .onchain {
proof.onchainAmountSats = payment.amount.atomic
}
+ proof.allowanceId = allowanceId
try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) }
}
@@ -1638,7 +1644,7 @@ actor PaykitPaymentProofService {
billingPeriod: pendingProof.billingPeriod?.sdkValue,
paymentAppId: pendingProof.paymentAppId,
paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier,
- allowanceId: nil,
+ allowanceId: pendingProof.allowanceId,
conversionQuoteId: pendingProof.conversionQuoteId,
proof: Paykit.PrivateJsonObject(text: proofText)
)
diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift
index 41edccae1..3c4451b98 100644
--- a/Bitkit/Services/PaykitPaymentRequestService.swift
+++ b/Bitkit/Services/PaykitPaymentRequestService.swift
@@ -1897,6 +1897,17 @@ final class PaykitPaymentRequestManager {
}
}
+ /// Re-reads the locally accepted request ids after another component saved one, such as the allowance executor.
+ /// Returns whether the set changed, so the caller refreshes before presenting.
+ @discardableResult
+ func reloadAcceptedRequestIds() -> Bool {
+ guard let identity = activeIdentity, let ids = try? acceptanceStore.load(identity: identity), ids != acceptedRequestIds else {
+ return false
+ }
+ acceptedRequestIds = ids
+ return true
+ }
+
func ensureOnchainRecoveryAllowed(
_ attempt: OnchainSendAttempt, proofService: PaykitPaymentProofService
) async throws -> (request: PaykitPaymentRequest, paymentDeadline: PaykitPreciseInstant?) {
@@ -2183,6 +2194,15 @@ final class PaykitPaymentRequestManager {
return true
}
+ /// Ends a requested presentation the allowance flow owns, so it does not hold every other Paykit sheet back.
+ func releaseRequestedPresentation(_ request: PaykitPaymentRequest) {
+ guard requestedPresentationId == request.id else { return }
+ requestedPresentationId = nil
+ presentationRetryAttempts.removeValue(forKey: request.id)
+ presentationRetryDeadlines.removeValue(forKey: request.id)
+ presentationGeneration += 1
+ }
+
func dismissPreparingRequest(_ request: PaykitPaymentRequest) {
guard pendingRequests.contains(where: { $0.id == request.id }),
requestedPresentationId == nil || requestedPresentationId == request.id,
diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift
index 129532b18..6de3653f8 100644
--- a/Bitkit/Services/PrivatePaykitService+Payments.swift
+++ b/Bitkit/Services/PrivatePaykitService+Payments.swift
@@ -1,3 +1,4 @@
+import BitkitCore
import Foundation
import Paykit
@@ -345,6 +346,28 @@ 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 {
+ guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else {
+ throw PrivatePaykitError.invalidPublicKey
+ }
+ guard var contactState = state.contacts[publicKey], contactState.consumedPrivatePaymentListVersion == version else { return }
+ let previousContactState = state.contacts[publicKey]
+ contactState.consumedPrivatePaymentListVersion = previousVersion
+ state.contacts[publicKey] = contactState
+ do {
+ try persistStateOrThrow(markWalletBackup: true)
+ } catch {
+ state.contacts[publicKey] = previousContactState
+ throw error
+ }
+ Logger.info(
+ "Released private Paykit payment list version \(version) for \(PubkyPublicKeyFormat.redacted(publicKey)) after a failed automatic payment",
+ context: "PrivatePaykit"
+ )
+ }
+
func consumedPaymentListVersion(publicKey: String, attemptId: UUID? = nil) -> UInt64? {
guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { return nil }
if let attemptId, let consumption = privatePaymentListConsumptions[PrivatePaymentListConsumptionKey(attemptId: attemptId, publicKey: key)] {
@@ -514,6 +537,66 @@ extension PrivatePaykitService {
persistState(markWalletBackup: true)
}
+ /// Resolves the payee's current private endpoint for an automatic Allowance payment. Only endpoints the Allowance and
+ /// the request both accept qualify, and only ones this wallet can pay without asking: a bolt11 invoice for exactly
+ /// the requested amount (or amount-less), or an unused on-chain address. Public endpoints are never used.
+ func resolveAllowancePayment(
+ _ request: PaykitPaymentRequest,
+ eligibleIdentifiers: [String]
+ ) async throws -> PrivatePaykitAllowancePayment? {
+ guard !request.isExpired(at: Date()),
+ let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty)
+ else { return nil }
+
+ let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion
+ // Resolve through the request, as the manual flow does, so its required app and bound destination apply.
+ let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest(
+ counterparty: publicKey,
+ paymentRequestId: request.paymentRequestId,
+ afterPrivatePaymentListVersion: consumedVersion
+ )
+ if prepared.resolution.state == .recoveryPending || prepared.resolution.status == .waitingForUpdatedPaymentList {
+ // The last list was already paid from; a new one arrives once the payee sees that payment settle.
+ schedulePrivatePaymentRecovery(for: publicKey)
+ throw PaykitAllowanceError.paymentListPending
+ }
+ // Without a payment list version nothing limits how often the payee's endpoints can be paid, so the request asks.
+ guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil }
+
+ let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers)
+ let candidates = resolvedEndpoints(from: prepared.resolution).filter {
+ eligible.contains($0.methodId.rawValue) &&
+ ($0.methodId == .bitcoinLightningBolt11 || $0.methodId.onchainNetwork != nil) && $0.appId != nil
+ }
+ let payable = await privatePayableEndpoints(from: candidates, publicKey: publicKey)
+
+ for methodId in PublicPaykitService.MethodId.payablePreferenceOrder {
+ guard let endpoint = payable.first(where: { $0.methodId == methodId }), let appId = endpoint.appId else { continue }
+ let context = PrivatePaykitPaymentContext(
+ paymentAppsByEndpoint: [methodId.rawValue: appId],
+ paymentListVersion: paymentListVersion
+ )
+ if methodId == .bitcoinLightningBolt11 {
+ guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value),
+ invoice.amountSatoshis == 0 || invoice.amountSatoshis == request.allowanceAmountSats
+ else { continue }
+ return PrivatePaykitAllowancePayment(
+ endpoint: endpoint,
+ context: context,
+ lightningPaymentHash: invoice.paymentHash.hex,
+ lightningInvoiceHasAmount: invoice.amountSatoshis != 0
+ )
+ }
+ return PrivatePaykitAllowancePayment(
+ endpoint: endpoint,
+ context: context,
+ lightningPaymentHash: nil,
+ lightningInvoiceHasAmount: false
+ )
+ }
+ return nil
+ }
+
private func resolvedEndpoints(from resolution: PrivateContactPaymentResolution) -> [PublicPaykitService.Endpoint] {
resolution.payableEndpoints.compactMap {
var endpoint = PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload)
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index b83a45334..943364481 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1211,6 +1211,125 @@ actor PaykitSdkService {
}
}
+ func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] {
+ try await operationLock.withLock {
+ try await handle().listAllowances(filter: filter)
+ }
+ }
+
+ func proposeAllowance(
+ counterparty: String,
+ localRole: Paykit.AllowanceLocalRole,
+ terms: Paykit.AllowanceTerms
+ ) async throws -> Paykit.AllowanceRecord {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.proposeAllowance(
+ counterparty: counterparty,
+ localRole: localRole,
+ terms: terms
+ )
+ }
+ }
+
+ func acceptAllowance(
+ counterparty: String,
+ allowanceId: String,
+ expectedProposalStreamItemId: UInt64
+ ) async throws -> Paykit.AllowanceRecord {
+ try await withStateRevisionTracking(priority: .interactive) { sdk in
+ try await sdk.acceptAllowance(
+ counterparty: counterparty,
+ allowanceId: allowanceId,
+ expectedProposalStreamItemId: expectedProposalStreamItemId
+ )
+ }
+ }
+
+ func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.rejectAllowance(counterparty: counterparty, allowanceId: allowanceId)
+ }
+ }
+
+ func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.endAllowance(counterparty: counterparty, allowanceId: allowanceId)
+ }
+ }
+
+ func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? {
+ try await operationLock.withLock {
+ try await handle().allowanceAccountingState()
+ }
+ }
+
+ func reconcileAllowanceAccounting(
+ _ reconciliation: Paykit.AllowanceAccountingReconciliation
+ ) async throws -> Paykit.AllowanceAccountingState {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.reconcileAllowanceAccounting(reconciliation: reconciliation)
+ }
+ }
+
+ func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: trustedTime)
+ }
+ }
+
+ func acceptPaymentRequestAutomatically(
+ scope: Paykit.PaymentRequestScope,
+ selection: Paykit.AllowanceSelectionInput,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.AllowanceAssociationRecord {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.acceptPaymentRequestAutomatically(scope: scope, selection: selection, checks: checks)
+ }
+ }
+
+ func reserveAutomaticPayment(
+ occurrence: Paykit.PaymentOccurrence,
+ expectedAssociationRevision: UInt64,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.PaymentAttemptDecision {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.reserveAutomaticPayment(
+ occurrence: occurrence,
+ expectedAssociationRevision: expectedAssociationRevision,
+ checks: checks
+ )
+ }
+ }
+
+ func reserveManualPayment(
+ occurrence: Paykit.PaymentOccurrence,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.PaymentAttemptDecision {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.reserveManualPayment(occurrence: occurrence, checks: checks)
+ }
+ }
+
+ func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.beginPaymentExecution(attemptId: attemptId, checks: checks)
+ }
+ }
+
+ @discardableResult
+ func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.recordPaymentOutcome(report: report)
+ }
+ }
+
+ @discardableResult
+ func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord {
+ try await withStateRevisionTracking { sdk in
+ try await sdk.markPaymentManualOnly(occurrence: occurrence)
+ }
+ }
+
func linkedPeers() async throws -> [LinkedPeerRecord] {
try await linkedPeers(priority: .ordered)
}
diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift
index af882b0ba..030f791e9 100644
--- a/Bitkit/Services/PublicPaykitService.swift
+++ b/Bitkit/Services/PublicPaykitService.swift
@@ -36,6 +36,13 @@ struct PrivatePaykitPaymentContext: Equatable {
}
}
+struct PrivatePaykitAllowancePayment: Equatable {
+ let endpoint: PublicPaykitService.Endpoint
+ let context: PrivatePaykitPaymentContext
+ let lightningPaymentHash: String?
+ let lightningInvoiceHasAmount: Bool
+}
+
enum IncomingPaykitPaymentRequestFailureReason: String, Hashable {
case noSupportedEndpoint = "no_supported_endpoint"
case endpointNotPayable = "endpoint_not_payable"
diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift
index 157cee373..f2da3def5 100644
--- a/Bitkit/Utilities/Keychain.swift
+++ b/Bitkit/Utilities/Keychain.swift
@@ -16,6 +16,7 @@ enum KeychainEntryType {
case paykitKeyGeneration(publicKey: String)
case paykitRecoveryBackup
case paykitPendingBackupRestore
+ case paykitAllowanceState
case pubkySecretKey
var storageKey: String {
@@ -34,6 +35,7 @@ enum KeychainEntryType {
case let .paykitKeyGeneration(publicKey): "paykit_key_generation_\(publicKey)"
case .paykitRecoveryBackup: "paykit_recovery_backup"
case .paykitPendingBackupRestore: "paykit_pending_backup_restore"
+ case .paykitAllowanceState: "paykit_allowance_state"
case .pubkySecretKey: "pubky_secret_key"
}
}
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 203fb7df9..36f14680c 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -1775,6 +1775,7 @@ extension AppViewModel {
paymentHash: paymentHash,
preimage: paymentPreimage
)
+ await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: true)
}
let outcome = QuickPayPaymentCoordinator.shared.complete(
paymentId: paymentId,
@@ -1808,7 +1809,10 @@ extension AppViewModel {
)
let hash = paymentHash ?? outcome.invoicePaymentHash ?? paymentId
if let paymentHash = paymentHash ?? paymentId {
- Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) }
+ Task {
+ await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash)
+ await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: false)
+ }
}
let awaitingSheet = hash.map { pendingPaymentHashes.contains($0) } ?? false
if let hash, awaitingSheet {
diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
index 6648e5a1b..8bb1193b1 100644
--- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
+++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
@@ -266,6 +266,7 @@ struct PaymentRequestsSheet: View {
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var sheets: SheetViewModel
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests
+ @Environment(PaykitAllowanceManager.self) private var allowances
let config: PaymentRequestsSheetItem
@@ -319,8 +320,15 @@ struct PaymentRequestsSheet: View {
navigation.navigate(.paymentRequestDetail(request.id))
},
onPay: {
- sheets.hideSheetBeforePerforming(reason: "Paying payment request") {
- _ = paymentRequests.requestPresentation(request)
+ Task {
+ // Closing the sheet first would leave nothing on screen behind the toast.
+ if await allowances.isAutomaticallyHandling(request) {
+ app.toast(type: .info, title: t("subscriptions__allowance_payment_in_progress"))
+ return
+ }
+ sheets.hideSheetBeforePerforming(reason: "Paying payment request") {
+ _ = paymentRequests.requestPresentation(request)
+ }
}
},
onReject: paymentRequests.canDismiss(request) ? {
@@ -377,6 +385,7 @@ struct PaymentRequestsView: View {
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var sheets: SheetViewModel
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests
+ @Environment(PaykitAllowanceManager.self) private var allowances
var body: some View {
Group {
@@ -502,7 +511,9 @@ struct PaymentRequestsView: View {
private func historyDate(for request: PaykitPaymentRequest) -> String {
guard let createdAt = request.createdAt else { return status(for: request) }
- return Self.dateFormatter.string(from: createdAt)
+ let date = Self.dateFormatter.string(from: createdAt)
+ guard allowances.autoPaidRequestIds.contains(request.id) else { return date }
+ return date + " · " + t("subscriptions__allowance_auto_paid")
}
private static let dateFormatter: DateFormatter = {
diff --git a/Bitkit/Views/Security/PaymentAuthorization.swift b/Bitkit/Views/Security/PaymentAuthorization.swift
new file mode 100644
index 000000000..811a5619d
--- /dev/null
+++ b/Bitkit/Views/Security/PaymentAuthorization.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+
+/// Asks for the same PIN or biometric check as a payment when the user requires one for payments.
+@MainActor
+@Observable
+final class PaymentAuthorization {
+ fileprivate(set) var isAskingForPin = false
+ fileprivate(set) var biometricErrorMessage: String?
+ @ObservationIgnored private var pinContinuation: CheckedContinuation?
+
+ func authorize(settings: SettingsViewModel) async -> Bool {
+ guard settings.requirePinForPayments && settings.pinEnabled else { return true }
+ if settings.useBiometrics && BiometricAuth.isAvailable {
+ switch await BiometricAuth.authenticate() {
+ case .success:
+ return true
+ case .cancelled:
+ return false
+ case let .failed(message):
+ biometricErrorMessage = message
+ return false
+ }
+ }
+ resolvePin(false)
+ isAskingForPin = true
+ return await withCheckedContinuation { pinContinuation = $0 }
+ }
+
+ fileprivate func resolvePin(_ approved: Bool) {
+ isAskingForPin = false
+ let continuation = pinContinuation
+ pinContinuation = nil
+ continuation?.resume(returning: approved)
+ }
+
+ fileprivate func dismissBiometricError() {
+ biometricErrorMessage = nil
+ }
+}
+
+private struct PaymentAuthorizationModifier: ViewModifier {
+ let authorization: PaymentAuthorization
+
+ func body(content: Content) -> some View {
+ content
+ .sheet(isPresented: Binding(
+ get: { authorization.isAskingForPin },
+ set: {
+ if !$0 {
+ authorization.resolvePin(false)
+ }
+ }
+ )) {
+ SendPinScreen(
+ onCancel: { authorization.resolvePin(false) },
+ onPinVerified: { authorization.resolvePin(true) }
+ )
+ }
+ .alert(
+ t("security__bio_error_title"),
+ isPresented: Binding(
+ get: { authorization.biometricErrorMessage != nil },
+ set: {
+ if !$0 {
+ authorization.dismissBiometricError()
+ }
+ }
+ )
+ ) {
+ Button(t("common__ok")) {}
+ } message: {
+ Text(authorization.biometricErrorMessage ?? "")
+ }
+ .onDisappear { authorization.resolvePin(false) }
+ }
+}
+
+extension View {
+ func paymentAuthorization(_ authorization: PaymentAuthorization) -> some View {
+ modifier(PaymentAuthorizationModifier(authorization: authorization))
+ }
+}
diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift
new file mode 100644
index 000000000..c46df7ae4
--- /dev/null
+++ b/Bitkit/Views/Subscriptions/AllowancesView.swift
@@ -0,0 +1,780 @@
+import SwiftUI
+
+// MARK: - Allowances tab
+
+struct AllowancesTab: View {
+ @Environment(PaykitAllowanceManager.self) private var allowances
+ @EnvironmentObject private var sheets: SheetViewModel
+
+ var body: some View {
+ Group {
+ if allowances.entries.isEmpty {
+ emptyState
+ } else {
+ list
+ }
+ }
+ .task {
+ await allowances.refresh()
+ }
+ }
+
+ private var list: some View {
+ TimelineView(.periodic(from: .now, by: 60)) { context in
+ LazyVStack(spacing: 12) {
+ ForEach(allowances.entries) { entry in
+ Button {
+ let route: SubscriptionSheetItem.Route = entry.primary.isAnswerable
+ ? .allowanceReview(entry)
+ : .allowanceDetail(entry)
+ sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: route))
+ } label: {
+ AllowanceRow(entry: entry, now: context.date)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.top, 32)
+ .padding(.bottom, ScreenLayout.floatingFooterClearance)
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ Spacer()
+
+ Image("group")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 256, height: 256)
+ .frame(maxWidth: .infinity)
+ .accessibilityHidden(true)
+
+ Spacer().frame(height: 32)
+
+ DisplayText(t("subscriptions__allowances_empty_headline"), accentColor: .purpleAccent)
+ Spacer().frame(height: 8)
+ BodyMText(t("subscriptions__allowances_empty_description"), textColor: .white64)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.bottom, ScreenLayout.floatingFooterClearance)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("AllowancesEmpty")
+ }
+}
+
+struct AllowanceRow: View {
+ @Environment(PaykitAllowanceManager.self) private var allowances
+ @EnvironmentObject private var currency: CurrencyViewModel
+
+ let entry: PaykitAllowanceEntry
+ let now: Date
+
+ private var status: PaykitAllowance.Status {
+ entry.status(at: now)
+ }
+
+ var body: some View {
+ HStack(spacing: 16) {
+ AllowanceCounterpartyAvatar(counterparty: entry.counterparty, size: 40)
+
+ VStack(alignment: .leading, spacing: 0) {
+ AllowanceCounterpartyName(counterparty: entry.counterparty)
+ CaptionBText(subtitle, textColor: .white64)
+ .lineLimit(1)
+ .accessibilityIdentifier("AllowanceRowStatus")
+ }
+
+ Spacer(minLength: 8)
+
+ VStack(alignment: .trailing, spacing: 0) {
+ AllowanceMoney(usd: entry.limits?.monthlyUsd, sats: entry.monthlyLimitSats, size: .bodyMSB)
+ CaptionBText(t("subscriptions__allowance_monthly_limit"), textColor: .white64)
+ .lineLimit(1)
+ }
+ }
+ .padding(16)
+ .background(Color.gray6)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .opacity(isInactive ? 0.64 : 1)
+ .contentShape(Rectangle())
+ .accessibilityElement(children: .combine)
+ .accessibilityIdentifier("AllowanceRow-\(entry.id)")
+ }
+
+ private var isInactive: Bool {
+ switch status {
+ case .ended, .declined, .expired, .conflicted: true
+ default: false
+ }
+ }
+
+ private var subtitle: String {
+ let perPayment = AllowanceAmountText.perPayment(entry, currency: currency)
+ switch status {
+ case .active:
+ return [t("subscriptions__allowance_status_active"), perPayment].compactMap { $0 }.joined(separator: " · ")
+ case .awaitingAnswer:
+ return t("subscriptions__allowance_status_waiting")
+ case .awaitingMyAnswer:
+ return t("subscriptions__allowance_status_needs_answer")
+ case .notYetActive:
+ return t("subscriptions__allowance_status_scheduled")
+ case .expired:
+ return t("subscriptions__allowance_status_expired")
+ case .declined:
+ return t("subscriptions__allowance_status_declined")
+ case .conflicted:
+ return t("subscriptions__allowance_status_conflicted")
+ case .ended:
+ let paid = allowances.autoPaidSats(for: entry)
+ guard paid > 0 else { return t("subscriptions__allowance_status_ended") }
+ return t("subscriptions__allowance_status_ended") + " · " +
+ t("subscriptions__allowance_paid_automatically", variables: ["amount": AllowanceAmountText.fiat(sats: paid, currency: currency)])
+ }
+ }
+}
+
+// MARK: - Shared pieces
+
+struct AllowanceCounterpartyAvatar: View {
+ @EnvironmentObject private var contactsManager: ContactsManager
+
+ let counterparty: String
+ let size: CGFloat
+
+ var body: some View {
+ if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, counterparty) }) {
+ PubkyContactAvatar(contact: contact, size: size)
+ } else {
+ ContactAvatarLetter(source: counterparty, size: size)
+ }
+ }
+}
+
+struct AllowanceCounterpartyName: View {
+ @EnvironmentObject private var contactsManager: ContactsManager
+
+ let counterparty: String
+
+ var body: some View {
+ BodyMSBText(name)
+ .lineLimit(1)
+ }
+
+ private var name: String {
+ contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, counterparty) }?.displayName
+ ?? PubkyPublicKeyFormat.displayTruncated(counterparty)
+ }
+}
+
+/// A limit shown in dollars: the label the Allower picked when there is one, otherwise the BTC terms at today's rate.
+struct AllowanceMoney: View {
+ @EnvironmentObject private var currency: CurrencyViewModel
+
+ enum Size {
+ case bodyMSB
+ case title
+ }
+
+ let usd: Decimal?
+ let sats: UInt64?
+ var size: Size = .bodyMSB
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 3) {
+ text("$", color: .white64)
+ text(amount, color: .textPrimary)
+ }
+ }
+
+ private var amount: String {
+ if let usd {
+ return AllowanceAmountText.formatted(usd)
+ }
+ guard let sats else { return "—" }
+ return AllowanceAmountText.limitValue(sats: sats, currency: currency)
+ }
+
+ @ViewBuilder
+ private func text(_ value: String, color: Color) -> some View {
+ switch size {
+ case .bodyMSB: BodyMSBText(value, textColor: color)
+ case .title: TitleText(value, textColor: color)
+ }
+ }
+}
+
+enum AllowanceAmountText {
+ static func formatted(_ usd: Decimal) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.minimumFractionDigits = 2
+ formatter.maximumFractionDigits = 2
+ formatter.locale = Locale(identifier: "en_US")
+ return formatter.string(from: usd as NSDecimalNumber) ?? "\(usd)"
+ }
+
+ static func short(_ usd: Decimal) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = 2
+ formatter.locale = Locale(identifier: "en_US")
+ return "$" + (formatter.string(from: usd as NSDecimalNumber) ?? "\(usd)")
+ }
+
+ @MainActor
+ static func fiatValue(sats: UInt64, currency: CurrencyViewModel) -> String {
+ guard let converted = currency.convert(sats: sats, to: "USD") else { return "—" }
+ return formatted(converted.value)
+ }
+
+ @MainActor
+ static func fiat(sats: UInt64, currency: CurrencyViewModel) -> String {
+ "$" + fiatValue(sats: sats, currency: currency)
+ }
+
+ /// A limit set in whole dollars on the other wallet, shown back from its BTC terms at today's rate: rounded to the
+ /// dollar so a small rate move does not turn $5 into $4.99.
+ @MainActor
+ static func limitValue(sats: UInt64, currency: CurrencyViewModel) -> String {
+ guard let converted = currency.convert(sats: sats, to: "USD") else { return "—" }
+ guard converted.value >= 1 else { return formatted(converted.value) }
+ var rounded = Decimal()
+ var value = converted.value
+ NSDecimalRound(&rounded, &value, 0, .plain)
+ return formatted(rounded)
+ }
+
+ @MainActor
+ static func perPayment(_ entry: PaykitAllowanceEntry, currency: CurrencyViewModel) -> String? {
+ if let usd = entry.limits?.perPaymentUsd {
+ return t("subscriptions__allowance_per_payment_short", variables: ["amount": short(usd)])
+ }
+ guard let sats = entry.perPaymentMaxSats else { return nil }
+ return t("subscriptions__allowance_per_payment_short", variables: ["amount": "$" + limitValue(sats: sats, currency: currency)])
+ }
+}
+
+private struct AllowanceLimitsGrid: View {
+ @EnvironmentObject private var currency: CurrencyViewModel
+
+ let entry: PaykitAllowanceEntry
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 16) {
+ cell(
+ title: t("subscriptions__allowance_per_payment"),
+ usd: entry.limits?.perPaymentUsd,
+ sats: entry.perPaymentMaxSats,
+ identifier: "AllowancePerPaymentValue"
+ )
+ cell(
+ title: t("subscriptions__allowance_each_month"),
+ usd: entry.limits?.monthlyUsd,
+ sats: entry.monthlyLimitSats,
+ identifier: "AllowanceMonthlyValue"
+ )
+ }
+ }
+
+ private func cell(title: String, usd: Decimal?, sats: UInt64?, identifier: String) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(title.localizedUppercase, textColor: .white64)
+ BodySSBText(t(
+ "subscriptions__allowance_up_to",
+ variables: ["amount": usd.map { "$" + AllowanceAmountText.formatted($0) } ?? sats.map { "$" + AllowanceAmountText.limitValue(
+ sats: $0,
+ currency: currency
+ ) } ?? "—"]
+ ))
+ .accessibilityIdentifier(identifier)
+ if let sats {
+ CaptionText("₿ " + sats.formattedWithSpaces, textColor: .white64)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct AllowanceCounterpartyCard: View {
+ @EnvironmentObject private var contactsManager: ContactsManager
+
+ let counterparty: String
+
+ var body: some View {
+ HStack(spacing: 16) {
+ AllowanceCounterpartyAvatar(counterparty: counterparty, size: 48)
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(PubkyPublicKeyFormat.displayTruncated(counterparty).localizedUppercase, textColor: .white64)
+ AllowanceCounterpartyName(counterparty: counterparty)
+ }
+ Spacer()
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityIdentifier("AllowanceCounterparty")
+ }
+}
+
+extension UInt64 {
+ var formattedWithSpaces: String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.groupingSeparator = " "
+ formatter.usesGroupingSeparator = true
+ return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
+ }
+}
+
+// MARK: - Choose a contact
+
+struct AllowanceContactView: View {
+ @EnvironmentObject private var contactsManager: ContactsManager
+
+ let onSelect: (PubkyContact) -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("subscriptions__allowance_choose_contact"))
+
+ if contactsManager.contacts.isEmpty {
+ BodyMText(t("subscriptions__allowance_no_contacts"), textColor: .white64)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 16)
+ Spacer()
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(contactsManager.contacts) { contact in
+ Button {
+ onSelect(contact)
+ } label: {
+ HStack(spacing: 16) {
+ PubkyContactAvatar(contact: contact, size: 48)
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(contact.profile.truncatedPublicKey.localizedUppercase, textColor: .white64)
+ BodyMSBText(contact.displayName)
+ }
+ Spacer()
+ }
+ .padding(.vertical, 12)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("AllowanceContact-\(contact.displayName)")
+ CustomDivider()
+ }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+}
+
+// MARK: - Set Allowance
+
+struct SetAllowanceView: View {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var currency: CurrencyViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ @Environment(PaykitAllowanceManager.self) private var allowances
+ @State private var authorization = PaymentAuthorization()
+
+ let contact: PubkyContact
+ let onBack: () -> Void
+ let onSaved: () -> Void
+
+ @State private var perPaymentIndex = 1
+ @State private var monthlyIndex = 2
+
+ private var perPaymentUsd: Decimal {
+ PaykitAllowanceLimits.perPaymentStopsUsd[perPaymentIndex]
+ }
+
+ private var monthlyUsd: Decimal {
+ PaykitAllowanceLimits.monthlyStopsUsd[monthlyIndex]
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t("subscriptions__allowance_set_title"), showBackButton: true, onBack: onBack)
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ AllowanceCounterpartyCard(counterparty: contact.publicKey)
+
+ BodyMText(
+ t("subscriptions__allowance_set_explanation", variables: ["name": contact.displayName]),
+ textColor: .white64
+ )
+
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("subscriptions__allowance_payment_limit").localizedUppercase, textColor: .white64)
+ .padding(.vertical, 16)
+ AllowanceStepSlider(
+ stops: PaykitAllowanceLimits.perPaymentStopsUsd,
+ selectedIndex: $perPaymentIndex,
+ identifier: "AllowancePerPayment"
+ )
+ }
+
+ CustomDivider()
+
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("subscriptions__allowance_monthly_allowance").localizedUppercase, textColor: .white64)
+ .padding(.vertical, 16)
+ AllowanceStepSlider(
+ stops: PaykitAllowanceLimits.monthlyStopsUsd,
+ selectedIndex: $monthlyIndex,
+ identifier: "AllowanceMonthly"
+ )
+ }
+
+ CustomDivider()
+
+ BodySText(
+ t(
+ "subscriptions__allowance_set_summary",
+ variables: [
+ "perPayment": AllowanceAmountText.short(perPaymentUsd),
+ "monthly": AllowanceAmountText.short(monthlyUsd),
+ ]
+ ),
+ textColor: .white64
+ )
+ .accessibilityIdentifier("AllowanceSummary")
+ }
+ .padding(.bottom, 16)
+ }
+
+ CustomButton(title: t("subscriptions__allowance_save"), isLoading: allowances.isWorking) {
+ await save()
+ }
+ .accessibilityIdentifier("AllowanceSave")
+ .padding(.bottom, 16)
+ }
+ .padding(.horizontal, 16)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("SetAllowance")
+ .paymentAuthorization(authorization)
+ }
+
+ private func save() async {
+ guard let perPaymentSats = currency.convert(fiatAmount: NSDecimalNumber(decimal: perPaymentUsd).doubleValue, from: "USD"),
+ let monthlySats = currency.convert(fiatAmount: NSDecimalNumber(decimal: monthlyUsd).doubleValue, from: "USD")
+ else {
+ app.toast(PaykitAllowanceError.unavailable)
+ return
+ }
+ let limits = PaykitAllowanceLimits(
+ perPaymentUsd: perPaymentUsd,
+ monthlyUsd: monthlyUsd,
+ perPaymentSats: perPaymentSats,
+ monthlySats: monthlySats
+ )
+ guard await authorization.authorize(settings: settings) else { return }
+ do {
+ try await allowances.propose(to: contact, limits: limits)
+ onSaved()
+ } catch {
+ app.toast(error)
+ }
+ }
+}
+
+/// A slider that snaps to fixed stops, drawn like the Figma allowance limits: a track, a tick per stop, and a knob.
+struct AllowanceStepSlider: View {
+ let stops: [Decimal]
+ @Binding var selectedIndex: Int
+ let identifier: String
+
+ private let knobSize: CGFloat = 32
+ private let trackHeight: CGFloat = 8
+
+ var body: some View {
+ VStack(spacing: 8) {
+ GeometryReader { geometry in
+ let width = geometry.size.width
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(Color.purpleAccent.opacity(0.32))
+ .frame(height: trackHeight)
+ Capsule()
+ .fill(Color.purpleAccent)
+ .frame(width: position(for: selectedIndex, width: width), height: trackHeight)
+ ForEach(stops.indices, id: \.self) { index in
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color.white)
+ .frame(width: 4, height: 16)
+ .offset(x: min(max(position(for: index, width: width) - 2, 0), width - 4))
+ }
+ Circle()
+ .fill(Color.purpleAccent)
+ .frame(width: knobSize, height: knobSize)
+ .overlay(Circle().fill(Color.white).frame(width: 16, height: 16))
+ .offset(x: position(for: selectedIndex, width: width) - knobSize / 2)
+ }
+ .frame(height: knobSize)
+ .contentShape(Rectangle())
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ select(nearestIndex(to: value.location.x, width: width))
+ }
+ )
+ }
+ .frame(height: knobSize)
+
+ GeometryReader { geometry in
+ ZStack(alignment: .topLeading) {
+ ForEach(stops.indices, id: \.self) { index in
+ Button {
+ select(index)
+ } label: {
+ CaptionMText(AllowanceAmountText.short(stops[index]), textColor: .textPrimary)
+ .frame(width: labelWidth, alignment: alignment(for: index))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .offset(x: labelOffset(for: index, width: geometry.size.width))
+ .accessibilityIdentifier("\(identifier)Stop-\(index)")
+ }
+ }
+ }
+ .frame(height: 18)
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier(identifier)
+ .accessibilityValue(AllowanceAmountText.short(stops[selectedIndex]))
+ .accessibilityAdjustableAction { direction in
+ switch direction {
+ case .increment: select(selectedIndex + 1)
+ case .decrement: select(selectedIndex - 1)
+ @unknown default: break
+ }
+ }
+ }
+
+ private func select(_ index: Int) {
+ let clamped = min(max(index, 0), stops.count - 1)
+ guard clamped != selectedIndex else { return }
+ selectedIndex = clamped
+ Haptics.play(.light)
+ }
+
+ private func position(for index: Int, width: CGFloat) -> CGFloat {
+ guard stops.count > 1 else { return width / 2 }
+ return width * CGFloat(index) / CGFloat(stops.count - 1)
+ }
+
+ private func nearestIndex(to x: CGFloat, width: CGFloat) -> Int {
+ guard stops.count > 1, width > 0 else { return 0 }
+ return Int((x / width * CGFloat(stops.count - 1)).rounded())
+ }
+
+ private let labelWidth: CGFloat = 56
+
+ private func labelOffset(for index: Int, width: CGFloat) -> CGFloat {
+ if index == 0 {
+ return 0
+ }
+ if index == stops.count - 1 {
+ return width - labelWidth
+ }
+ return position(for: index, width: width) - labelWidth / 2
+ }
+
+ private func alignment(for index: Int) -> Alignment {
+ if index == 0 {
+ return .leading
+ }
+ if index == stops.count - 1 {
+ return .trailing
+ }
+ return .center
+ }
+}
+
+// MARK: - Review (the side that answers a proposal)
+
+struct AllowanceReviewView: View {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var settings: SettingsViewModel
+ @Environment(PaykitAllowanceManager.self) private var allowances
+ @State private var authorization = PaymentAuthorization()
+
+ let entry: PaykitAllowanceEntry
+
+ private var counterpartyName: String {
+ contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, entry.counterparty) }?.displayName
+ ?? PubkyPublicKeyFormat.displayTruncated(entry.counterparty)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t("subscriptions__allowance_title"))
+
+ DisplayText(
+ entry.role == .allowee
+ ? t("subscriptions__allowance_offer_headline", variables: ["name": counterpartyName])
+ : t("subscriptions__allowance_request_headline", variables: ["name": counterpartyName]),
+ accentColor: .purpleAccent
+ )
+ .padding(.top, 16)
+ .padding(.bottom, 16)
+
+ AllowanceCounterpartyCard(counterparty: entry.counterparty)
+ .padding(16)
+ .background(Color.gray6)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+ AllowanceLimitsGrid(entry: entry)
+ .padding(.top, 24)
+
+ BodyMText(
+ entry.role == .allowee
+ ? t("subscriptions__allowance_offer_explanation", variables: ["name": counterpartyName])
+ : t("subscriptions__allowance_request_explanation", variables: ["name": counterpartyName]),
+ textColor: .white64
+ )
+ .padding(.top, 24)
+
+ Spacer()
+
+ HStack(spacing: 16) {
+ CustomButton(title: t("subscriptions__allowance_decline"), variant: .secondary, isDisabled: allowances.isWorking) {
+ await respond(accept: false)
+ }
+ .accessibilityIdentifier("AllowanceDecline")
+ CustomButton(title: t("subscriptions__allowance_accept"), isLoading: allowances.isWorking) {
+ await respond(accept: true)
+ }
+ .accessibilityIdentifier("AllowanceAccept")
+ }
+ .padding(.bottom, 16)
+ }
+ .padding(.horizontal, 16)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("AllowanceReview")
+ .paymentAuthorization(authorization)
+ .task {
+ // Another sheet's dismissal can close this one right after it opens; only a sheet the user saw counts.
+ try? await Task.sleep(for: .seconds(1))
+ guard !Task.isCancelled else { return }
+ await allowances.markProposalPresented(entry)
+ }
+ }
+
+ private func respond(accept: Bool) async {
+ // Accepting as the payer grants spending power, like a payment.
+ if accept, entry.role == .allower {
+ guard await authorization.authorize(settings: settings) else { return }
+ }
+ do {
+ if accept {
+ try await allowances.accept(entry)
+ } else {
+ try await allowances.reject(entry)
+ }
+ sheets.hideSheet(reason: accept ? "Allowance accepted" : "Allowance declined")
+ } catch {
+ app.toast(error)
+ }
+ }
+}
+
+// MARK: - Detail
+
+struct AllowanceDetailView: View {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var currency: CurrencyViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ @Environment(PaykitAllowanceManager.self) private var allowances
+ @State private var authorization = PaymentAuthorization()
+
+ let entry: PaykitAllowanceEntry
+
+ private var current: PaykitAllowanceEntry {
+ allowances.entry(id: entry.id) ?? entry
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t("subscriptions__allowance_title"))
+
+ AllowanceCounterpartyCard(counterparty: current.counterparty)
+ .padding(16)
+ .background(Color.gray6)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+
+ AllowanceLimitsGrid(entry: current)
+ .padding(.top, 24)
+
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("subscriptions__allowance_paid_so_far").localizedUppercase, textColor: .white64)
+ BodySSBText(AllowanceAmountText.fiat(sats: allowances.autoPaidSats(for: current), currency: currency))
+ .accessibilityIdentifier("AllowancePaidSoFar")
+ }
+ .padding(.top, 24)
+
+ BodyMText(explanation, textColor: .white64)
+ .padding(.top, 24)
+ .accessibilityIdentifier("AllowanceDetailStatus")
+
+ Spacer()
+
+ if current.canEnd {
+ SwipeButton(
+ title: current.primary.lifecycleState == .proposed
+ ? t("subscriptions__allowance_swipe_withdraw")
+ : t("subscriptions__allowance_swipe_end"),
+ accentColor: .purpleAccent,
+ isLoading: allowances.isWorking
+ ) {
+ if current.role == .allower {
+ guard await authorization.authorize(settings: settings) else { throw CancellationError() }
+ }
+ do {
+ try await allowances.end(current)
+ sheets.hideSheet(reason: "Allowance ended")
+ } catch {
+ app.toast(error)
+ throw error
+ }
+ }
+ .padding(.bottom, 16)
+ }
+ }
+ .padding(.horizontal, 16)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("AllowanceDetail")
+ .paymentAuthorization(authorization)
+ }
+
+ private var explanation: String {
+ switch current.status(at: Date()) {
+ case .active:
+ current.role == .allower
+ ? t("subscriptions__allowance_detail_active_allower")
+ : t("subscriptions__allowance_detail_active_allowee")
+ case .awaitingAnswer:
+ t("subscriptions__allowance_status_waiting")
+ case .awaitingMyAnswer:
+ t("subscriptions__allowance_status_needs_answer")
+ case .notYetActive:
+ t("subscriptions__allowance_status_scheduled")
+ case .expired:
+ t("subscriptions__allowance_status_expired")
+ case .declined:
+ t("subscriptions__allowance_status_declined")
+ case .ended:
+ t("subscriptions__allowance_detail_ended")
+ case .conflicted:
+ t("subscriptions__allowance_status_conflicted")
+ }
+ }
+}
diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift
index 36854e4d7..24e25c5b1 100644
--- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift
+++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift
@@ -12,6 +12,10 @@ struct SubscriptionSheetItem: SheetItem {
case details(PaykitSubscription)
case cancel(PaykitSubscription)
case payment(SendRoute)
+ case allowanceContact
+ case allowanceSet(PubkyContact)
+ case allowanceReview(PaykitAllowanceEntry)
+ case allowanceDetail(PaykitAllowanceEntry)
}
let route: Route
@@ -22,11 +26,13 @@ struct SubscriptionSheetItem: SheetItem {
struct SubscriptionsView: View {
private enum Tab: String, CustomStringConvertible {
case overview
+ case allowances
case payments
var description: String {
switch self {
case .overview: t("subscriptions__overview")
+ case .allowances: t("subscriptions__allowances")
case .payments: t("subscriptions__payments")
}
}
@@ -74,6 +80,8 @@ struct SubscriptionsView: View {
Group {
if selectedTab == .payments {
PaymentRequestsView()
+ } else if selectedTab == .allowances {
+ AllowancesTab()
} else if !hasVisibleSubscriptions {
emptyState
} else {
@@ -119,6 +127,7 @@ struct SubscriptionsView: View {
selectedTab: $selectedTab,
tabItems: [
TabItem(.overview),
+ TabItem(.allowances),
TabItem(.payments, badge: paymentRequests.pendingRequests.count),
],
inactiveColor: .white.opacity(0.5)
@@ -149,6 +158,11 @@ struct SubscriptionsView: View {
sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create))
}
.accessibilityIdentifier("SubscriptionCreate")
+ } else if selectedTab == .allowances {
+ CustomButton(title: t("subscriptions__allowance_add")) {
+ sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceContact))
+ }
+ .accessibilityIdentifier("AllowanceAdd")
} else {
PaymentRequestsFooterButton()
}
@@ -618,6 +632,18 @@ struct SubscriptionSheet: View {
cancel(subscription)
case let .payment(sendRoute):
SendSheet(config: SendSheetItem(initialRoute: sendRoute), isEmbedded: true)
+ case .allowanceContact:
+ AllowanceContactView { route = .allowanceSet($0) }
+ case let .allowanceSet(contact):
+ SetAllowanceView(
+ contact: contact,
+ onBack: { route = .allowanceContact },
+ onSaved: { sheets.hideSheet(reason: "Allowance proposed") }
+ )
+ case let .allowanceReview(entry):
+ AllowanceReviewView(entry: entry)
+ case let .allowanceDetail(entry):
+ AllowanceDetailView(entry: entry)
}
}
.task {
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 0cde300eb..3d303cfa1 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -1167,6 +1167,7 @@ struct SendConfirmationView: View {
var lightningPaymentSubmitted = false
var originalPaymentIdentity: String?
var privatePaymentListOutcome = PrivatePaymentListSendOutcome.definitePreBroadcastFailure
+ var manualAllowanceAttemptId: String?
do {
try validateIncomingPaymentRequestContext(contactPaymentContext)
@@ -1201,6 +1202,13 @@ struct SendConfirmationView: View {
return
}
+ if let incomingPaymentRequest, let preparedPaymentProof {
+ manualAllowanceAttemptId = try await PaykitAllowanceExecutor.shared.beginManualPayment(
+ incomingPaymentRequest,
+ paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier
+ )
+ }
+
if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice {
let amount = wallet.sendAmountSats ?? invoice.amountSatoshis
// Set the amount for other screens
@@ -1214,6 +1222,9 @@ struct SendConfirmationView: View {
paymentHash: paymentHash
)
}
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.manualLightningPaymentSent(attemptId: manualAllowanceAttemptId, paymentHash: paymentHash)
+ }
createdMetadataPaymentId = paymentHash
await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash)
@@ -1304,6 +1315,9 @@ struct SendConfirmationView: View {
onAuthorized: { _ in
onchainPaymentStarted = true
privatePaymentListOutcome = .uncertain
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.manualOnchainPaymentSending(attemptId: manualAllowanceAttemptId)
+ }
},
send: { beforeBroadcastAttempt in
try await wallet.send(
@@ -1340,6 +1354,9 @@ struct SendConfirmationView: View {
title: t("wallet__onchain_broadcast_rejected"),
description: t("wallet__onchain_broadcast_rejected_note", variables: ["reason": reason])
)
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .unknown)
+ }
await navigationPath.append(Self.onchainPendingRoute(txid: rejectedTxid, requestId: incomingPaymentRequest?.id))
return
case let .unknown(unknownTxid):
@@ -1350,11 +1367,21 @@ struct SendConfirmationView: View {
title: t("wallet__onchain_broadcast_unconfirmed"),
description: t("wallet__onchain_broadcast_unconfirmed_note")
)
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .unknown)
+ }
await navigationPath.append(Self.onchainPendingRoute(txid: unknownTxid, requestId: incomingPaymentRequest?.id))
return
}
privatePaymentListOutcome = .succeeded
await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome)
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.finishManualPayment(
+ attemptId: manualAllowanceAttemptId,
+ outcome: .succeeded,
+ transactionId: txid
+ )
+ }
var proofSaved = true
if let incomingPaymentRequest, let preparedPaymentProof {
proofSaved = await PaykitPaymentProofService.shared.completeOnchainPayment(
@@ -1399,6 +1426,9 @@ struct SendConfirmationView: View {
)
}
} catch is CancellationError {
+ if let manualAllowanceAttemptId, !lightningPaymentSubmitted, !onchainPaymentStarted {
+ await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .failed)
+ }
if shouldCancelPaymentProof, let incomingPaymentRequest {
await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest)
}
@@ -1436,6 +1466,9 @@ struct SendConfirmationView: View {
case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable,
.preDispatchCleanupFailed:
await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain)
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .unknown)
+ }
shouldCancelPaymentProof = false
app.toast(attemptError)
await navigationPath.append(Self.onchainPendingRoute(requestId: incomingPaymentRequest?.id))
@@ -1444,10 +1477,16 @@ struct SendConfirmationView: View {
break
}
}
+ await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome)
+ if let manualAllowanceAttemptId {
+ await PaykitAllowanceExecutor.shared.finishManualPayment(
+ attemptId: manualAllowanceAttemptId,
+ outcome: lightningPaymentSubmitted || onchainPaymentStarted ? .unknown : .failed
+ )
+ }
if shouldCancelPaymentProof, let incomingPaymentRequest {
await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest)
}
- await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome)
Logger.error("Payment failed: \(error)")
if let paymentId = createdMetadataPaymentId {
diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift
new file mode 100644
index 000000000..e9a74bc3e
--- /dev/null
+++ b/BitkitTests/PaykitAllowanceExecutorTests.swift
@@ -0,0 +1,1819 @@
+@testable import Bitkit
+import Combine
+import Foundation
+import Paykit
+import XCTest
+
+final class PaykitAllowanceExecutorTests: XCTestCase {
+ private typealias Fixtures = PaykitAllowanceFixtures
+
+ private static let admissionCalls = [
+ "evaluateAllowanceCandidates",
+ "claimForExecution",
+ "recordAcceptanceIntent",
+ "acceptPaymentRequestAutomatically",
+ "reserveAutomaticPayment",
+ "receivePrivateMessages",
+ "beginPaymentExecution",
+ "consumePaymentList",
+ "ensurePaymentAllowed",
+ "prepareProof",
+ "associateLightningPayment",
+ "payLightning",
+ "resolvePaymentList",
+ ]
+
+ // MARK: Admission
+
+ func testUncoveredRequestNeverReachesTheSdk() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ let uncovering: [[PaykitAllowance]] = [
+ [],
+ [Fixtures.allowance(role: .allowee)],
+ [Fixtures.allowance(state: .proposed)],
+ [Fixtures.allowance(state: .ended)],
+ [Fixtures.allowance(counterparty: Fixtures.otherCounterpartyKey)],
+ ]
+
+ for allowances in uncovering {
+ let result = await harness.executor.autoPay(request, allowances: allowances, identity: Fixtures.identityKey)
+ XCTAssertEqual(result, .notCovered)
+ }
+ XCTAssertEqual(harness.log.entries, [])
+ }
+
+ func testCoveredLightningRequestRunsAdmissionInOrderAndHandsOffToTheNode() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+
+ let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .started)
+ let log = harness.log.entries
+ XCTAssertEqual(log.filter(Self.admissionCalls.contains), Self.admissionCalls)
+ let resolveIndex = try XCTUnwrap(log.firstIndex(of: "resolve"))
+ let acceptIndex = try XCTUnwrap(log.firstIndex(of: "acceptPaymentRequestAutomatically"))
+ XCTAssertLessThan(resolveIndex, acceptIndex)
+ XCTAssertFalse(log.contains("recordPaymentOutcome"))
+
+ let evaluatedTimes = await harness.sdk.evaluatedTrustedTimes
+ XCTAssertEqual(evaluatedTimes, [PaykitAllowanceTime.format(Fixtures.now)])
+ let selections = await harness.sdk.selections
+ XCTAssertEqual(selections.map(\.allowanceId), [Fixtures.walletAllowanceId])
+ let acceptedEndpoints = await harness.sdk.acceptedEndpointIdentifiers
+ XCTAssertEqual(acceptedEndpoints, [Fixtures.lightningIdentifier])
+ let reservedRevisions = await harness.sdk.reservedAssociationRevisions
+ XCTAssertEqual(reservedRevisions, [1])
+ let preparedProofs = await harness.payer.preparedProofs
+ XCTAssertEqual(
+ preparedProofs,
+ [.init(appId: AllowanceHarness.paymentAppId, endpoint: Fixtures.lightningIdentifier, allowanceId: Fixtures.walletAllowanceId)]
+ )
+ let claimed = await harness.payer.claimedRequestIds
+ XCTAssertEqual(claimed, [request.id])
+ let intents = await harness.payer.acceptanceIntents
+ XCTAssertEqual(intents, [request.id], "The request stays this device's own once accepted, so a failed payment comes back as manual")
+ XCTAssertFalse(harness.log.entries.contains("discardAcceptanceIntent"))
+ let listOutcomes = await harness.payer.paymentListOutcomes
+ XCTAssertEqual(listOutcomes, [.uncertain], "A hand-off to the node keeps the payment list consumed")
+ let associatedHashes = await harness.payer.associatedPaymentHashes
+ XCTAssertEqual(associatedHashes, [AllowanceHarness.paymentHash])
+ let payments = await harness.payer.lightningPayments
+ XCTAssertEqual(payments, [.init(bolt11: AllowanceHarness.invoice, sats: nil)])
+
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.count, 1)
+ let entry = try XCTUnwrap(journal.first)
+ XCTAssertEqual(entry.attemptId, AllowanceSdkMock.automaticAttemptId)
+ XCTAssertEqual(entry.stage, .sent)
+ XCTAssertTrue(entry.isAutomatic)
+ XCTAssertEqual(entry.requestId, request.id)
+ XCTAssertEqual(entry.allowanceId, Fixtures.walletAllowanceId)
+ XCTAssertEqual(entry.amountSats, 1000)
+ XCTAssertEqual(entry.paymentHash, AllowanceHarness.paymentHash)
+ XCTAssertEqual(entry.paymentEndpointIdentifier, Fixtures.lightningIdentifier)
+ let isHandling = await harness.executor.isHandling(request.id)
+ XCTAssertFalse(isHandling)
+ }
+
+ func testRequestWaitsWithoutAcceptanceWhileThePayeesPaymentListIsPending() async throws {
+ let harness = AllowanceHarness()
+ await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending)
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .deferred)
+ let log = harness.log.entries
+ XCTAssertTrue(log.contains("resolve"))
+ XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically"))
+ XCTAssertFalse(log.contains("markPaymentManualOnly"))
+ }
+
+ @MainActor
+ func testManagerKeepsCoveredRequestsOffTheSendSheetUntilLightningIsReady() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())])
+ var ready = false
+ let manager = PaykitAllowanceManager(
+ sdk: harness.sdk,
+ executor: harness.executor,
+ now: { PaykitAllowanceFixtures.now },
+ canPayNow: { ready }
+ )
+ await manager.activate(identity: Fixtures.identityKey)
+ let request = try Fixtures.paymentRequest()
+
+ let handledWhileReconnecting = await manager.processIncomingRequests([request])
+
+ XCTAssertFalse(handledWhileReconnecting)
+ XCTAssertFalse(harness.log.entries.contains("evaluateAllowanceCandidates"))
+ let waiting = await manager.isAutomaticallyHandling(request)
+ XCTAssertTrue(waiting)
+
+ ready = true
+ let handled = await manager.processIncomingRequests([request])
+
+ XCTAssertTrue(handled)
+ XCTAssertTrue(harness.log.entries.contains("payLightning"))
+ }
+
+ @MainActor
+ func testManagerOwnsACoveredRequestBeforeTheFirstPassAndReleasesItOnceFoundManual() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())])
+ await harness.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range"))])
+ let manager = PaykitAllowanceManager(
+ sdk: harness.sdk,
+ executor: harness.executor,
+ now: { PaykitAllowanceFixtures.now },
+ canPayNow: { true }
+ )
+ await manager.activate(identity: Fixtures.identityKey)
+ let covered = try Fixtures.paymentRequest()
+ let uncovered = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002", counterparty: Fixtures.otherCounterpartyKey)
+
+ let ownsBeforeAnyPass = await manager.isAutomaticallyHandling(covered)
+ let ownsUncovered = await manager.isAutomaticallyHandling(uncovered)
+
+ XCTAssertTrue(ownsBeforeAnyPass, "No sheet may open between the request arriving and the first pass over it")
+ XCTAssertFalse(ownsUncovered)
+
+ _ = await manager.processIncomingRequests([covered, uncovered])
+
+ let ownsAfterManual = await manager.isAutomaticallyHandling(covered)
+ XCTAssertFalse(ownsAfterManual, "A request the pass found manual goes to the Send sheet")
+ }
+
+ @MainActor
+ func testManagerHandsBackARequestAboveTheLimitsWhileWaitingForAChannel() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())])
+ let manager = PaykitAllowanceManager(
+ sdk: harness.sdk,
+ executor: harness.executor,
+ now: { PaykitAllowanceFixtures.now },
+ canPayNow: { false }
+ )
+ await manager.activate(identity: Fixtures.identityKey)
+ let aboveLimit = try Fixtures.paymentRequest(amount: "0.0001")
+ let withinLimits = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002")
+ let events = AllowanceEventRecorder()
+
+ let handedBack = await manager.processIncomingRequests([aboveLimit, withinLimits])
+
+ XCTAssertTrue(handedBack)
+ let ownsAboveLimit = await manager.isAutomaticallyHandling(aboveLimit)
+ let ownsWithinLimits = await manager.isAutomaticallyHandling(withinLimits)
+ XCTAssertFalse(ownsAboveLimit, "A request over the limits goes to the Send sheet without waiting for a channel")
+ XCTAssertTrue(ownsWithinLimits, "A request within the limits waits for the channel")
+ XCTAssertEqual(events.events.filter {
+ if case .limitReached = $0 {
+ true
+ } else {
+ false
+ }
+ }.count, 1)
+ XCTAssertFalse(harness.log.entries.contains("payLightning"))
+ }
+
+ @MainActor
+ func testManagerMayCoverAContactItWasAskedToGrant() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: Fixtures.standardTerms())])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+ await manager.activate(identity: Fixtures.identityKey)
+
+ XCTAssertTrue(try manager.mayCover(Fixtures.paymentRequest()))
+ XCTAssertFalse(try manager.mayCover(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey)))
+ XCTAssertFalse(manager.isUpdating)
+ }
+
+ @MainActor
+ func testManagerMayNotCoverAContactWhoseAllowanceEnded() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(state: .ended, terms: Fixtures.standardTerms())])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+ await manager.activate(identity: Fixtures.identityKey)
+
+ XCTAssertFalse(try manager.mayCover(Fixtures.paymentRequest()), "An ended allowance can't start covering a request")
+ }
+
+ @MainActor
+ func testManagerKeepsADeferredRequestOffTheSendSheet() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())])
+ await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending)
+ let manager = PaykitAllowanceManager(
+ sdk: harness.sdk,
+ executor: harness.executor,
+ now: { PaykitAllowanceFixtures.now },
+ canPayNow: { true }
+ )
+ await manager.activate(identity: Fixtures.identityKey)
+ let request = try Fixtures.paymentRequest()
+
+ let handled = await manager.processIncomingRequests([request])
+
+ XCTAssertFalse(handled)
+ let waiting = await manager.isAutomaticallyHandling(request)
+ XCTAssertTrue(waiting)
+ }
+
+ func testBlockedCandidateStaysManualWithoutAcceptance() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setCandidates([
+ AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range")),
+ ])
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ let log = harness.log.entries
+ XCTAssertTrue(log.contains("evaluateAllowanceCandidates"))
+ XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically"))
+ XCTAssertFalse(log.contains("reserveAutomaticPayment"))
+ XCTAssertFalse(log.contains("resolve"))
+ XCTAssertFalse(log.contains("payLightning"))
+ }
+
+ func testOverMonthlyCapStaysManualAndNotifiesOnce() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setAccountingState(Self.stateNearTheMonthlyCap())
+ let request = try Fixtures.paymentRequest()
+ let events = AllowanceEventRecorder()
+
+ let first = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+ let second = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(first, .manual)
+ XCTAssertEqual(second, .manual)
+ let log = harness.log.entries
+ XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically"))
+ XCTAssertFalse(log.contains("reserveAutomaticPayment"))
+ XCTAssertFalse(log.contains("resolve"))
+ let limitEvents = events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 1000) }
+ XCTAssertEqual(limitEvents.count, 1)
+ }
+
+ func testRequestAbovePerPaymentLimitNotifiesOnceWhenTheSdkBlocksIt() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "per_payment_amount_exceeded"))])
+ let request = try Fixtures.paymentRequest()
+ let allowance = Fixtures.allowance(perPaymentMaxSats: 500)
+ let events = AllowanceEventRecorder()
+
+ let first = await harness.executor.autoPay(request, allowances: [allowance], identity: Fixtures.identityKey)
+ let second = await harness.executor.autoPay(request, allowances: [allowance], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(first, .manual)
+ XCTAssertEqual(second, .manual)
+ let limitEvents = events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 1000) }
+ XCTAssertEqual(limitEvents.count, 1)
+ }
+
+ func testPaymentIsNotSentWhenTheSendingStageCannotBeSaved() async throws {
+ let harness = AllowanceHarness()
+ harness.store.failSaves()
+ let request = try Fixtures.paymentRequest()
+
+ let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ XCTAssertFalse(harness.log.entries.contains("payLightning"))
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)])
+ let listOutcomes = await harness.payer.paymentListOutcomes
+ XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure])
+ }
+
+ func testReservationBlockedForAReasonOtherThanALimitRaisesNoLimitReached() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setAutomaticReservation(.blocked(reason: .manualOnly))
+ let events = AllowanceEventRecorder()
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ XCTAssertFalse(events.events.contains {
+ if case .limitReached = $0 {
+ true
+ } else {
+ false
+ }
+ })
+ }
+
+ func testBlockedReservationMarksThePaymentManualOnly() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setAutomaticReservation(.blocked(reason: .sharedRule(code: "period_amount_limit")))
+ let request = try Fixtures.paymentRequest()
+
+ let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ let manualOnly = await harness.sdk.manualOnlyOccurrences
+ XCTAssertEqual(manualOnly, [
+ Paykit.PaymentOccurrence(
+ request: Paykit.PaymentRequestScope(
+ counterparty: request.counterparty,
+ paymentRequestId: request.paymentRequestId
+ ),
+ billingPeriod: nil
+ ),
+ ])
+ let log = harness.log.entries
+ XCTAssertFalse(log.contains("beginPaymentExecution"))
+ XCTAssertFalse(log.contains("payLightning"))
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal, [])
+ }
+
+ func testBlockedBeginRecordsAFailedOutcome() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setBeginDecision(.blocked(reason: .manualOnly))
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)])
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.stage), [.failed])
+ let log = harness.log.entries
+ XCTAssertFalse(log.contains("consumePaymentList"))
+ XCTAssertFalse(log.contains("prepareProof"))
+ XCTAssertFalse(log.contains("payLightning"))
+ }
+
+ func testClaimFailureStaysManualBeforeAnyAcceptance() async throws {
+ let harness = AllowanceHarness()
+ await harness.payer.setClaimError(PaykitPaymentRequestError.requestUnavailable)
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ let log = harness.log.entries
+ XCTAssertTrue(log.contains("claimForExecution"))
+ XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically"))
+ XCTAssertFalse(log.contains("reserveAutomaticPayment"))
+ XCTAssertFalse(log.contains("payLightning"))
+ }
+
+ func testRefusedAcceptanceDropsTheIntentButAnInterruptedOneKeepsIt() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ await harness.sdk.setAcceptError(PaykitError.Policy(code: "refused", context: "refused"))
+
+ let refused = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(refused, .manual)
+ var intents = await harness.payer.acceptanceIntents
+ XCTAssertEqual(intents, [], "A refused Acceptance leaves nothing to reconcile")
+
+ await harness.sdk.setAcceptError(PaykitError.Transport(code: "timeout", context: "interrupted"))
+ let interrupted = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(interrupted, .manual)
+ intents = await harness.payer.acceptanceIntents
+ XCTAssertEqual(intents, [request.id], "An interrupted response may follow a committed Acceptance")
+ XCTAssertFalse(harness.log.entries.contains("reserveAutomaticPayment"))
+ }
+
+ func testNodeRejectionBeforeRoutingReleasesThePaymentListAndRecordsAFailure() async throws {
+ let harness = AllowanceHarness()
+ await harness.payer.setLightningError(AllowanceMockError.unsupported)
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ let listOutcomes = await harness.payer.paymentListOutcomes
+ XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure])
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)])
+ XCTAssertTrue(harness.log.entries.contains("failLightningPayment"))
+ }
+
+ func testUncertainLightningSubmissionErrorKeepsTheReservation() async throws {
+ let harness = AllowanceHarness()
+ await harness.payer.setLightningError(AllowanceMockError.unsupported)
+ await harness.payer.setLightningErrorIsDefinite(false)
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .started)
+ let listOutcomes = await harness.payer.paymentListOutcomes
+ XCTAssertEqual(listOutcomes, [.uncertain])
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)])
+ }
+
+ func testSettlementDuringTheSendIsNotReplacedBySent() async throws {
+ let harness = AllowanceHarness()
+ let gate = await harness.payer.holdLightning()
+ let request = try Fixtures.paymentRequest()
+ 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.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: true)
+ gate.open()
+ _ = await caller.value
+
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.stage), [.succeeded])
+ }
+
+ // MARK: Cancellation
+
+ func testLightningPaymentStillStartsWhenItsCallerIsCancelledDuringTheSend() async throws {
+ let harness = AllowanceHarness()
+ let gate = await harness.payer.holdLightning()
+ let request = try Fixtures.paymentRequest()
+
+ let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) }
+ await Self.waitUntil { harness.log.entries.contains("payLightning") }
+ caller.cancel()
+ gate.open()
+ let result = await caller.value
+
+ XCTAssertEqual(result, .started)
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.stage), [.sent])
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [], "The settlement event, not the caller, records a Lightning outcome")
+ let listOutcomes = await harness.payer.paymentListOutcomes
+ XCTAssertEqual(listOutcomes, [.uncertain])
+ }
+
+ func testOnchainAutoPaymentFeeIsNotSpentOutsideTheAllowance() async throws {
+ let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment())
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ XCTAssertFalse(harness.log.entries.contains("claimForExecution"))
+ XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically"))
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [])
+ }
+
+ // MARK: Settlement and recovery
+
+ func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(
+ attemptId: "attempt-1",
+ request: request,
+ stage: .sent,
+ paymentHash: AllowanceHarness.paymentHash
+ )]),
+ identity: Fixtures.identityKey
+ )
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ let events = AllowanceEventRecorder()
+
+ await harness.executor.lightningPaymentSettled(paymentHash: String(repeating: "f", count: 64), succeeded: true)
+ let outcomesForUnknownHash = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomesForUnknownHash, [])
+
+ await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash.uppercased(), succeeded: true)
+
+ let outcomes = await harness.sdk.recordedOutcomes
+ 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)
+ }
+ XCTAssertEqual(paidEvents.count, 1)
+ XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, [])
+ }
+
+ func testExecutionStartupErrorReleasesTheReservation() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setBeginError(AllowanceMockError.unsupported)
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, .manual)
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)])
+ XCTAssertFalse(harness.log.entries.contains("payLightning"))
+ }
+
+ func testRefusedAcceptanceKeepsAnIntentTheManualFlowSaved() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ await harness.payer.seedAcceptanceIntent(request.id)
+ await harness.sdk.setAcceptError(AllowanceMockError.unsupported)
+
+ _ = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ let intents = await harness.payer.acceptanceIntents
+ XCTAssertEqual(intents, [request.id])
+ XCTAssertFalse(harness.log.entries.contains("discardAcceptanceIntent"))
+ }
+
+ func testRecoveryAnnouncesAnAutomaticPaymentItSettles() 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-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash)]),
+ identity: Fixtures.identityKey
+ )
+ await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))])
+ let events = AllowanceEventRecorder()
+
+ await harness.executor.recover(identity: Fixtures.identityKey)
+
+ let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) }
+ XCTAssertEqual(paid.count, 1)
+ }
+
+ func testRecoveryAnnouncesAPaymentReconciliationAlreadySettled() 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-settled", attempts: [Fixtures.attemptRecord(id: "settled", status: .succeeded)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "settled", request: request, stage: .sent, paymentHash: paidHash)]),
+ identity: Fixtures.identityKey
+ )
+ let events = AllowanceEventRecorder()
+
+ await harness.executor.recover(identity: Fixtures.identityKey)
+ await harness.executor.recover(identity: Fixtures.identityKey)
+
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.stage), [.succeeded])
+ let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) }
+ XCTAssertEqual(paid.count, 1)
+ }
+
+ func testSettlementAndRecoveryAnnounceOnePaymentOnce() 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-sent", attempts: [Fixtures.attemptRecord(id: "sent", status: .submitted)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent", request: request, stage: .sent, paymentHash: paidHash)]),
+ identity: Fixtures.identityKey
+ )
+ await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))])
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ let events = AllowanceEventRecorder()
+
+ await harness.executor.lightningPaymentSettled(paymentHash: paidHash, succeeded: true)
+ await harness.executor.recover(identity: Fixtures.identityKey)
+ await harness.executor.lightningPaymentSettled(paymentHash: paidHash, succeeded: true)
+
+ let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) }
+ XCTAssertEqual(paid.count, 1)
+ }
+
+ func testLimitCheckIgnoresAnExpiredAllowance() async throws {
+ let harness = AllowanceHarness()
+ let active = Fixtures.allowance(allowanceId: "active", perPaymentMaxSats: 500)
+ let expired = Fixtures.allowance(allowanceId: "expired", perPaymentMaxSats: 5000, expiresAt: Fixtures.now.addingTimeInterval(-60))
+
+ let exceeds = try await harness.executor.exceedsLimits(
+ Fixtures.paymentRequest(),
+ allowances: [active, expired],
+ identity: Fixtures.identityKey
+ )
+
+ XCTAssertTrue(exceeds, "Only an allowance active now can take a request")
+ }
+
+ func testPaymentListReleasedBeforeBroadcastIsNeverReleasedAgain() async throws {
+ let harness = AllowanceHarness()
+ await harness.payer.setLightningError(AllowanceMockError.unsupported)
+
+ _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ 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 journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.paymentListVersion), [nil])
+ }
+
+ func testFailedAutomaticPaymentReleasesThePaymentListItConsumed() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(
+ attemptId: "sent", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 7
+ )]),
+ identity: Fixtures.identityKey
+ )
+ await harness.executor.activate(identity: Fixtures.identityKey)
+
+ await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false)
+
+ let released = await harness.payer.releasedLists
+ XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 7, previousVersion: 6)])
+ }
+
+ func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [
+ Fixtures.occurrence(requestId: "req-killed", attempts: [Fixtures.attemptRecord(id: "killed", status: .submitted)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "killed", request: request, stage: .submitted, paymentListVersion: 3)]),
+ identity: Fixtures.identityKey
+ )
+
+ await harness.executor.recover(identity: Fixtures.identityKey)
+
+ let released = await harness.payer.releasedLists
+ XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 3, previousVersion: 2)])
+ }
+
+ func testRecoveryReleasesThePaymentListOfAnAttemptReconciliationFailed() 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: .sent, paymentListVersion: 5)]),
+ identity: Fixtures.identityKey
+ )
+
+ await harness.executor.recover(identity: Fixtures.identityKey)
+ await harness.executor.recover(identity: Fixtures.identityKey)
+
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.stage), [.failed])
+ let released = await harness.payer.releasedLists
+ XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 5, previousVersion: 4)])
+ }
+
+ func testRecoveryNeverReleasesAListASettlementReleasedWhileItWaited() async throws {
+ let harness = AllowanceHarness()
+ let first = try Fixtures.paymentRequest()
+ let second = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002")
+ let secondHash = String(repeating: "2", count: 64)
+ try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [
+ Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .failed)]),
+ Fixtures.occurrence(requestId: "req-b", attempts: [Fixtures.attemptRecord(id: "b", status: .failed)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [
+ Self.journalEntry(attemptId: "a", request: first, stage: .sent, paymentListVersion: 5),
+ Self.journalEntry(attemptId: "b", request: second, stage: .sent, paymentHash: secondHash, paymentListVersion: 7),
+ ]),
+ identity: Fixtures.identityKey
+ )
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ let gate = await harness.payer.holdNextRelease()
+
+ let recovery = Task { await harness.executor.recover(identity: Fixtures.identityKey) }
+ await Self.waitUntil { harness.log.entries.contains("releaseConsumedPaymentList") }
+ await harness.executor.lightningPaymentSettled(paymentHash: secondHash, succeeded: false)
+ gate.open()
+ await recovery.value
+
+ let released = await harness.payer.releasedLists.map(\.version)
+ XCTAssertEqual(released.filter { $0 == 7 }.count, 1, "The second attempt's list is released once, by the settlement")
+ }
+
+ func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ let paidHash = String(repeating: "1", count: 64)
+ let pendingHash = String(repeating: "2", count: 64)
+ try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [
+ Fixtures.occurrence(requestId: "req-prepared", attempts: [Fixtures.attemptRecord(id: "prepared", status: .prepared)]),
+ Fixtures.occurrence(requestId: "req-old-epoch", attempts: [Fixtures.attemptRecord(id: "old-epoch", status: .prepared, epoch: "epoch-0")]),
+ Fixtures.occurrence(requestId: "req-never-sent", attempts: [Fixtures.attemptRecord(id: "never-sent", status: .submitted)]),
+ Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]),
+ Fixtures.occurrence(requestId: "req-sent-pending", attempts: [Fixtures.attemptRecord(id: "sent-pending", status: .submitted)]),
+ Fixtures.occurrence(requestId: "req-done", attempts: [Fixtures.attemptRecord(id: "done", status: .succeeded)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [
+ Self.journalEntry(attemptId: "prepared", request: request, stage: .prepared),
+ Self.journalEntry(attemptId: "never-sent", request: request, stage: .submitted),
+ Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash),
+ Self.journalEntry(attemptId: "sent-pending", request: request, stage: .sent, paymentHash: pendingHash),
+ ]),
+ identity: Fixtures.identityKey
+ )
+ await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64)), pendingHash: .pending])
+
+ await harness.executor.recover(identity: Fixtures.identityKey)
+
+ let outcomes = await harness.sdk.recordedOutcomes
+ XCTAssertEqual(
+ Dictionary(uniqueKeysWithValues: outcomes.map { ($0.attemptId, $0.outcome) }),
+ ["prepared": .failed, "never-sent": .failed, "sent-paid": .succeeded, "sent-pending": .unknown]
+ )
+ XCTAssertEqual(outcomes.count, 4)
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(
+ Dictionary(uniqueKeysWithValues: journal.map { ($0.attemptId, $0.stage) }),
+ ["prepared": .failed, "never-sent": .failed, "sent-paid": .succeeded, "sent-pending": .unknown]
+ )
+ let payerCalls = await harness.payer.callCount
+ XCTAssertEqual(payerCalls, 0, "Recovery must never touch the payer, so nothing is paid twice")
+ XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, [])
+ let reconciliations = await harness.sdk.reconciliations
+ XCTAssertEqual(reconciliations.count, 0)
+ }
+
+ // MARK: Manual payments
+
+ func testManualPaymentThrowsWhenTheRequestIsAlreadyRecorded() async throws {
+ let harness = AllowanceHarness()
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ await harness.sdk.setManualReservation(.blocked(reason: .paymentAlreadyRecorded))
+ let request = try Fixtures.paymentRequest()
+
+ do {
+ _ = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier)
+ XCTFail("Expected alreadyRecorded")
+ } catch PaykitAllowanceManualPaymentError.alreadyRecorded {
+ // expected
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ let log = harness.log.entries
+ XCTAssertFalse(log.contains("beginPaymentExecution"))
+ }
+
+ func testManualPaymentOfARequestNotPricedInBitcoinStaysOutOfTheLedger() async throws {
+ let harness = AllowanceHarness()
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ let request = try Fixtures.paymentRequest(amount: "5.00", asset: PaykitAsset.usd.rawValue)
+
+ let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier)
+
+ XCTAssertNil(attemptId)
+ XCTAssertFalse(harness.log.entries.contains("reserveManualPayment"))
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal, [])
+ }
+
+ func testManualPaymentIsJournaledAndSubmittedWhenReady() async throws {
+ let harness = AllowanceHarness()
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ let request = try Fixtures.paymentRequest()
+
+ let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier)
+
+ XCTAssertEqual(attemptId, AllowanceSdkMock.manualAttemptId)
+ let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal
+ XCTAssertEqual(journal.map(\.stage), [.submitted])
+ XCTAssertEqual(journal.first?.isAutomatic, false)
+
+ await harness.sdk.setManualReservation(.blocked(reason: .manualOnly))
+ let blocked = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier)
+ XCTAssertNil(blocked)
+ }
+
+ // MARK: Trusted time and reconciliation
+
+ func testTrustedTimeNeverMovesBackwards() async {
+ let harness = AllowanceHarness()
+ let start = Fixtures.now
+
+ let first = await harness.executor.trustedTime(identity: Fixtures.identityKey)
+ harness.clock.set(start.addingTimeInterval(-3600))
+ let afterClockMovedBack = await harness.executor.trustedTime(identity: Fixtures.identityKey)
+ harness.clock.set(start.addingTimeInterval(60))
+ let afterClockMovedForward = await harness.executor.trustedTime(identity: Fixtures.identityKey)
+
+ XCTAssertEqual(first, PaykitAllowanceTime.format(start))
+ XCTAssertEqual(afterClockMovedBack, PaykitAllowanceTime.format(start))
+ XCTAssertEqual(afterClockMovedForward, PaykitAllowanceTime.format(start.addingTimeInterval(60)))
+ let stored = await harness.executor.localState(identity: Fixtures.identityKey).lastTrustedTime
+ XCTAssertEqual(stored, start.addingTimeInterval(60))
+ }
+
+ func testMissingLedgerIsReconciledWithAnEmptyHistory() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setAccountingState(nil)
+
+ let reconciled = try await harness.executor.ensureReconciled(identity: Fixtures.identityKey)
+ _ = try await harness.executor.ensureReconciled(identity: Fixtures.identityKey)
+
+ let reconciliations = await harness.sdk.reconciliations
+ XCTAssertEqual(reconciliations.count, 1)
+ let reconciliation = try XCTUnwrap(reconciliations.first)
+ XCTAssertNil(reconciliation.expectedRevision)
+ XCTAssertTrue(reconciliation.history.associations.isEmpty)
+ XCTAssertTrue(reconciliation.history.occurrences.isEmpty)
+ XCTAssertTrue(reconciliation.history.watermarks.isEmpty)
+ XCTAssertEqual(reconciliation.outcomes, [])
+ XCTAssertEqual(reconciliation.trustedTime, PaykitAllowanceTime.format(Fixtures.now))
+ XCTAssertFalse(reconciled.requiresReconciliation)
+ }
+
+ func testLedgerThatRequiresReconciliationIsReconciledAtItsRevision() async throws {
+ let harness = AllowanceHarness()
+ let request = try Fixtures.paymentRequest()
+ let paidHash = String(repeating: "3", count: 64)
+ try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 7, requiresReconciliation: true, occurrences: [
+ Fixtures.occurrence(requestId: "req-prepared", attempts: [Fixtures.attemptRecord(id: "prepared", status: .prepared)]),
+ Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]),
+ ]))
+ harness.store.seed(
+ PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash)]),
+ identity: Fixtures.identityKey
+ )
+ await harness.lookup.setStatuses([paidHash: .succeeded(preimage: nil)])
+
+ try await harness.executor.ensureReconciled(identity: Fixtures.identityKey)
+
+ let reconciliations = await harness.sdk.reconciliations
+ XCTAssertEqual(reconciliations.count, 1)
+ let reconciliation = try XCTUnwrap(reconciliations.first)
+ XCTAssertEqual(reconciliation.expectedRevision, 7)
+ XCTAssertEqual(reconciliation.history.occurrences.flatMap(\.attempts).map(\.attemptId), ["prepared", "sent-paid"])
+ XCTAssertEqual(reconciliation.outcomes, [
+ Paykit.PaymentOutcomeReport(attemptId: "prepared", outcome: .failed),
+ Paykit.PaymentOutcomeReport(attemptId: "sent-paid", outcome: .succeeded),
+ ])
+ let payerCalls = await harness.payer.callCount
+ XCTAssertEqual(payerCalls, 0)
+ }
+
+ // MARK: Trusted time vs subscription clock offset
+
+ func testAdmissionUsesInjectedTimeWhileSubscriptionClockIsOffset() async throws {
+ snapshotAppDefaults(SubscriptionClock.offsetDaysKey)
+ UserDefaults.standard.set(400, forKey: SubscriptionClock.offsetDaysKey)
+ try XCTSkipUnless(SubscriptionClock.offsetDays() == 400, "The subscription clock offset is unavailable in this build")
+ let harness = AllowanceHarness()
+ try await harness.sdk.setAccountingState(Self.stateNearTheMonthlyCap())
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(
+ result,
+ .manual,
+ "September's paid attempts must count; the subscription clock offset would have moved the window a year ahead"
+ )
+ let evaluatedTimes = await harness.sdk.evaluatedTrustedTimes
+ XCTAssertEqual(evaluatedTimes, [PaykitAllowanceTime.format(Fixtures.now)])
+ XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically"))
+ }
+
+ // MARK: Manager
+
+ @MainActor
+ func testAcceptingAProposalNeverAnswersAnotherContactsProposalWithTheSameId() async throws {
+ let harness = AllowanceHarness()
+ let sharedId = Fixtures.otherAllowanceId
+ let offer = try Fixtures.record(
+ allowanceId: sharedId, localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(),
+ proposedByMe: false, lastEventAt: "2026-09-02T10:00:00Z"
+ )
+ let ask = try Fixtures.record(
+ allowanceId: sharedId, counterparty: Fixtures.otherCounterpartyKey, localRole: .allower, state: .proposed,
+ terms: Fixtures.standardTerms(), proposedByMe: false, lastEventAt: "2026-09-01T10:00:00Z"
+ )
+ await harness.sdk.setRecords([offer, ask])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+ await manager.activate(identity: Fixtures.identityKey)
+
+ let presented = try XCTUnwrap(manager.proposalForPresentation())
+ XCTAssertEqual(presented.role, .allowee)
+ XCTAssertEqual(presented.counterparty, Fixtures.counterpartyKey)
+ try await manager.accept(presented)
+
+ let accepted = await harness.sdk.acceptedCounterparties
+ XCTAssertEqual(accepted, [Fixtures.counterpartyKey], "Accepting C's offer must not accept M's ask as payer")
+ }
+
+ @MainActor
+ func testPresentingOneContactsProposalKeepsAnotherContactsProposalWithTheSameIdPresentable() async throws {
+ let harness = AllowanceHarness()
+ let sharedId = Fixtures.otherAllowanceId
+ let offer = try Fixtures.record(
+ allowanceId: sharedId, localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(),
+ proposedByMe: false, lastEventAt: "2026-09-02T10:00:00Z"
+ )
+ let ask = try Fixtures.record(
+ allowanceId: sharedId, counterparty: Fixtures.otherCounterpartyKey, localRole: .allower, state: .proposed,
+ terms: Fixtures.standardTerms(), proposedByMe: false, lastEventAt: "2026-09-01T10:00:00Z"
+ )
+ await harness.sdk.setRecords([offer, ask])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+ await manager.activate(identity: Fixtures.identityKey)
+
+ let first = try XCTUnwrap(manager.proposalForPresentation())
+ await manager.markProposalPresented(first)
+
+ let second = try XCTUnwrap(manager.proposalForPresentation())
+ XCTAssertNotEqual(second.counterparty, first.counterparty)
+ }
+
+ @MainActor
+ func testManagerPresentsEachGrantAsOneEntryWithItsLimitsAndLeavesInvalidHistoryOut() async throws {
+ let harness = AllowanceHarness()
+ let terms = try Fixtures.standardTerms()
+ await harness.sdk.setRecords([
+ Fixtures.record(allowanceId: Fixtures.walletAllowanceId, terms: terms),
+ Fixtures.record(allowanceId: Fixtures.otherAllowanceId, counterparty: Fixtures.otherCounterpartyKey, terms: terms),
+ Fixtures.record(allowanceId: "allowance-invalid", historyStatus: .invalid, terms: terms),
+ ])
+ let group = PaykitAllowanceLocalState.Group(
+ id: "group-1",
+ counterparty: Fixtures.counterpartyKey,
+ limits: Fixtures.limits,
+ allowanceIds: [Fixtures.walletAllowanceId],
+ createdAt: Fixtures.now
+ )
+ harness.store.seed(PaykitAllowanceLocalState(groups: [group]), identity: Fixtures.identityKey)
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+
+ await manager.activate(identity: Fixtures.identityKey)
+
+ let entries = manager.entries
+ XCTAssertEqual(entries.map(\.id), ["group-1", "\(Fixtures.otherCounterpartyKey)/\(Fixtures.otherAllowanceId)"])
+ let grant = try XCTUnwrap(entries.first)
+ XCTAssertEqual(grant.allowances.map(\.allowanceId), [Fixtures.walletAllowanceId])
+ XCTAssertEqual(grant.limits, Fixtures.limits)
+ XCTAssertEqual(grant.counterparty, Fixtures.counterpartyKey)
+ XCTAssertEqual(grant.role, .allower)
+ XCTAssertEqual(grant.perPaymentMaxSats, 5000)
+ XCTAssertEqual(grant.monthlyLimitSats, 50000)
+ XCTAssertEqual(grant.status(at: Fixtures.now), .active)
+ XCTAssertNil(entries.last?.limits)
+ XCTAssertEqual(manager.entry(id: "group-1")?.primary.allowanceId, Fixtures.walletAllowanceId)
+ }
+
+ @MainActor
+ func testManagerCoversRequestsFromTheGrantedIdentityOnly() 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)
+
+ XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest()), "One Encrypted Link per identity: no folder is part of coverage")
+ XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey)))
+ }
+
+ @MainActor
+ func testManagerLeavesRequestsNotPricedInBitcoinManual() 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 usdRequest = try Fixtures.paymentRequest(amount: "5.00", asset: PaykitAsset.usd.rawValue)
+ XCTAssertFalse(usdRequest.isAllowancePayable)
+ XCTAssertFalse(manager.coversRequest(usdRequest))
+ let isHandling = await manager.isAutomaticallyHandling(usdRequest)
+ XCTAssertFalse(isHandling, "A USD request reaches the Send sheet instead of waiting for an automatic payment")
+
+ let convertedRequest = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")]))
+ XCTAssertTrue(convertedRequest.isAllowancePayable, "A bitcoin request that also prices a USDT rail pays over bitcoin")
+ XCTAssertTrue(manager.coversRequest(convertedRequest))
+ }
+
+ func testManualPaymentOverTheUsdtRailStaysOutOfTheLedger() async throws {
+ let harness = AllowanceHarness()
+ await harness.executor.activate(identity: Fixtures.identityKey)
+ let request = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")]))
+
+ let attemptId = try await harness.executor.beginManualPayment(
+ request,
+ paymentEndpointIdentifier: PublicPaykitService.MethodId.usdtArbitrum.rawValue
+ )
+
+ XCTAssertNil(attemptId)
+ XCTAssertFalse(harness.log.entries.contains("reserveManualPayment"))
+ }
+
+ @MainActor
+ func testGrantsNameOnlyBitcoinPaymentEndpoints() {
+ XCTAssertFalse(PaykitAllowanceManager.allowedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue))
+ XCTAssertTrue(PaykitAllowanceManager.allowedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue))
+ }
+
+ // MARK: Granting
+
+ @MainActor
+ func testProposeWaitsForTheContactsLinkAndThenProposesOneAllowance() async throws {
+ let harness = AllowanceHarness()
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+ await manager.activate(identity: Fixtures.identityKey)
+ let contact = Fixtures.contact()
+
+ for state in [Paykit.LinkedPeerState.notLinked, .linking, .recoveryRequired, .blocked] {
+ await harness.sdk.setPeers([Fixtures.linkedPeer(state: state)])
+ do {
+ try await manager.propose(to: contact, limits: Fixtures.limits)
+ XCTFail("Expected contactNotLinked while the link is \(state)")
+ } catch PaykitAllowanceError.contactNotLinked {
+ // expected
+ }
+ }
+ var proposals = await harness.sdk.proposals
+ XCTAssertEqual(proposals.count, 0, "A grant never reaches a contact that has no usable link")
+ XCTAssertTrue(manager.entries.isEmpty)
+
+ await harness.sdk.setPeers([Fixtures.linkedPeer(state: .linked)])
+ try await manager.propose(to: contact, limits: Fixtures.limits)
+
+ proposals = await harness.sdk.proposals
+ XCTAssertEqual(proposals.count, 1, "One link per identity means one SDK Allowance")
+ XCTAssertEqual(proposals.first?.counterparty, Fixtures.counterpartyKey)
+ XCTAssertEqual(proposals.first?.localRole, .allower)
+ XCTAssertEqual(proposals.first?.terms.perPaymentAmount()?.maximum(), "0.00005")
+ XCTAssertEqual(proposals.first?.terms.allowedPaymentEndpointIdentifiers(), PaykitAllowanceManager.allowedPaymentEndpointIdentifiers)
+ XCTAssertTrue(harness.log.entries.contains("processOutboundPrivateMessages"))
+ let entry = try XCTUnwrap(manager.entries.first)
+ XCTAssertEqual(entry.allowances.map(\.allowanceId), ["proposed-1"])
+ XCTAssertEqual(entry.limits, Fixtures.limits)
+ XCTAssertEqual(entry.status(at: Fixtures.now), .awaitingAnswer)
+ }
+
+ @MainActor
+ func testProposeIgnoresALinkedPeerThatIsAnotherContact() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setPeers([Fixtures.linkedPeer(counterparty: Fixtures.otherCounterpartyKey, state: .linked)])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+ await manager.activate(identity: Fixtures.identityKey)
+
+ do {
+ try await manager.propose(to: Fixtures.contact(), limits: Fixtures.limits)
+ XCTFail("Expected contactNotLinked")
+ } catch PaykitAllowanceError.contactNotLinked {
+ // expected
+ }
+ let proposals = await harness.sdk.proposals
+ XCTAssertEqual(proposals.count, 0)
+ }
+
+ @MainActor
+ func testManagerCoverageUsesInjectedTimeWhileSubscriptionClockIsOffset() async throws {
+ snapshotAppDefaults(SubscriptionClock.offsetDaysKey)
+ UserDefaults.standard.set(400, forKey: SubscriptionClock.offsetDaysKey)
+ try XCTSkipUnless(SubscriptionClock.offsetDays() == 400, "The subscription clock offset is unavailable in this build")
+ let harness = AllowanceHarness()
+ let expiring = try Fixtures.customTerms(expiresAt: Fixtures.now.addingTimeInterval(30 * 24 * 60 * 60))
+ await harness.sdk.setRecords([Fixtures.record(terms: expiring)])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+
+ await manager.activate(identity: Fixtures.identityKey)
+
+ XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest()))
+ XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey)))
+ }
+
+ @MainActor
+ func testManagerLeavesRequestsCreatedBeforeAcceptanceManual() async throws {
+ let harness = AllowanceHarness()
+ let acceptedAt = "2026-09-24T11:30:00Z"
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: acceptedAt)])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now })
+
+ await manager.activate(identity: Fixtures.identityKey)
+
+ XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:00:00Z")))
+ XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:29:40Z")), "Within the clock tolerance")
+ XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:45:00Z")))
+ }
+
+ // MARK: Helpers
+
+ @MainActor
+ func testReceivedRequestWithoutAmountCeilingIsDropped() async throws {
+ let harness = AllowanceHarness()
+ // Valid for the SDK: an expiry alone satisfies "an amount ceiling or expiry".
+ let uncapped = try Paykit.AllowanceTerms(
+ asset: PaykitIssuerInterop.bitcoinAsset,
+ perPaymentAmount: nil,
+ periodLimits: [],
+ lifetimeAmountLimit: nil,
+ activeFrom: nil,
+ expiresAt: "2100-01-01T00:00:00Z",
+ allowedPaymentEndpointIdentifiers: nil
+ )
+ await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: uncapped, proposedByMe: false)])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true })
+ await manager.activate(identity: Fixtures.identityKey)
+
+ XCTAssertEqual(manager.entries, [])
+ XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: uncapped, proposedByMe: false)))
+ XCTAssertNotNil(PaykitAllowance(record: Fixtures.record(localRole: .allowee, terms: uncapped, proposedByMe: false)))
+ }
+
+ @MainActor
+ func testManagerLeavesARequestWithUnknownCreationTimeManual() async throws {
+ let harness = AllowanceHarness()
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())])
+ let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true })
+ await manager.activate(identity: Fixtures.identityKey)
+ let request = try Fixtures.paymentRequest(createdAt: "not-a-timestamp")
+ XCTAssertNil(request.createdAt)
+
+ XCTAssertFalse(manager.coversRequest(request))
+ }
+
+ private static func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while !condition(), Date() < deadline {
+ try? await Task.sleep(nanoseconds: 5_000_000)
+ }
+ }
+
+ /// Three succeeded automatic payments this month total 49,500 sats of the 50,000 sat cap.
+ private static func stateNearTheMonthlyCap() throws -> Paykit.AllowanceAccountingState {
+ try Fixtures.accountingState(occurrences: [
+ Fixtures.occurrence(requestId: "paid-1", attempts: [
+ Fixtures.attemptRecord(id: "paid-1", amount: "0.0002", admittedAt: "2026-09-05T10:00:00Z", status: .succeeded),
+ ]),
+ Fixtures.occurrence(requestId: "paid-2", attempts: [
+ Fixtures.attemptRecord(id: "paid-2", amount: "0.0002", admittedAt: "2026-09-12T10:00:00Z", status: .succeeded),
+ ]),
+ Fixtures.occurrence(requestId: "paid-3", attempts: [
+ Fixtures.attemptRecord(id: "paid-3", amount: "0.000095", admittedAt: "2026-09-20T10:00:00Z", status: .succeeded),
+ ]),
+ ])
+ }
+
+ private static func journalEntry(
+ attemptId: String,
+ request: PaykitPaymentRequest,
+ stage: PaykitAllowanceLocalState.Stage,
+ paymentHash: String? = nil,
+ paymentListVersion: UInt64? = nil
+ ) -> PaykitAllowanceLocalState.JournalEntry {
+ PaykitAllowanceLocalState.JournalEntry(
+ attemptId: attemptId,
+ isAutomatic: true,
+ requestId: request.id,
+ allowanceId: Fixtures.walletAllowanceId,
+ amountSats: request.allowanceAmountSats,
+ paymentEndpointIdentifier: Fixtures.lightningIdentifier,
+ paymentHash: paymentHash,
+ onchainAddress: nil,
+ transactionId: nil,
+ stage: stage,
+ createdAt: Fixtures.now,
+ paymentListVersion: paymentListVersion,
+ previousPaymentListVersion: paymentListVersion.map { $0 - 1 }
+ )
+ }
+}
+
+// MARK: - Test doubles
+
+private struct AllowanceHarness {
+ static let paymentHash = String(repeating: "ab", count: 32)
+ static let invoice = "lnbcrt10u1allowancetestinvoice"
+ static let paymentAppId = "bitkit"
+
+ let log = AllowanceCallLog()
+ let store = AllowanceMemoryStore()
+ let clock = AllowanceTestClock(PaykitAllowanceFixtures.now)
+ let sdk: AllowanceSdkMock
+ let payer: AllowancePayerMock
+ let lookup: AllowanceLightningLookupMock
+ let executor: PaykitAllowanceExecutor
+
+ init(payment: PrivatePaykitAllowancePayment = Self.lightningPayment()) {
+ sdk = AllowanceSdkMock(log: log)
+ payer = AllowancePayerMock(log: log, payment: payment)
+ lookup = AllowanceLightningLookupMock(log: log)
+ let clock = clock
+ executor = PaykitAllowanceExecutor(sdk: sdk, store: store, payer: payer, lightningLookup: lookup, now: { clock.now() })
+ }
+
+ static func candidate(blocked: Paykit.AllowanceAccountingBlock? = nil) -> Paykit.AllowanceCandidate {
+ Paykit.AllowanceCandidate(
+ allowanceId: PaykitAllowanceFixtures.walletAllowanceId,
+ eligiblePaymentEndpointIdentifiers: [PaykitAllowanceFixtures.lightningIdentifier],
+ blocked: blocked
+ )
+ }
+
+ static let onchainAddress = "bcrt1qallowancetestaddress"
+
+ static func onchainPayment() -> PrivatePaykitAllowancePayment {
+ PrivatePaykitAllowancePayment(
+ endpoint: PublicPaykitService.Endpoint(
+ methodId: .bitcoinOnchainP2wpkh,
+ value: onchainAddress,
+ min: nil,
+ max: nil,
+ rawPayload: "{\"value\":\"\(onchainAddress)\"}"
+ ),
+ context: PrivatePaykitPaymentContext(
+ paymentAppsByEndpoint: [PaykitAllowanceFixtures.onchainIdentifier: paymentAppId],
+ paymentListVersion: 3
+ ),
+ lightningPaymentHash: nil,
+ lightningInvoiceHasAmount: false
+ )
+ }
+
+ static func lightningPayment() -> PrivatePaykitAllowancePayment {
+ PrivatePaykitAllowancePayment(
+ endpoint: PublicPaykitService.Endpoint(
+ methodId: .bitcoinLightningBolt11,
+ value: invoice,
+ min: nil,
+ max: nil,
+ rawPayload: "{\"value\":\"\(invoice)\"}"
+ ),
+ context: PrivatePaykitPaymentContext(
+ paymentAppsByEndpoint: [PaykitAllowanceFixtures.lightningIdentifier: paymentAppId],
+ paymentListVersion: 3
+ ),
+ lightningPaymentHash: paymentHash,
+ lightningInvoiceHasAmount: true
+ )
+ }
+}
+
+private final class AllowanceCallLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storage: [String] = []
+
+ var entries: [String] {
+ lock.withLock { storage }
+ }
+
+ func append(_ entry: String) {
+ lock.withLock { storage.append(entry) }
+ }
+}
+
+private final class AllowanceTestClock: @unchecked Sendable {
+ private let lock = NSLock()
+ private var date: Date
+
+ init(_ date: Date) {
+ self.date = date
+ }
+
+ func now() -> Date {
+ lock.withLock { date }
+ }
+
+ func set(_ newDate: Date) {
+ lock.withLock { date = newDate }
+ }
+}
+
+private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sendable {
+ private let lock = NSLock()
+ private var states: [String: PaykitAllowanceLocalState] = [:]
+ private var savesFail = false
+
+ func load(identity: String) throws -> PaykitAllowanceLocalState {
+ lock.withLock { states[identity] ?? PaykitAllowanceLocalState() }
+ }
+
+ func save(_ state: PaykitAllowanceLocalState, identity: String) throws {
+ try lock.withLock {
+ if savesFail {
+ throw AllowanceMockError.unsupported
+ }
+ states[identity] = state
+ }
+ }
+
+ func failSaves() {
+ lock.withLock { savesFail = true }
+ }
+
+ func seed(_ state: PaykitAllowanceLocalState, identity: String) {
+ lock.withLock { states[identity] = state }
+ }
+}
+
+private final class AllowanceEventRecorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storage: [PaykitAllowanceEvent] = []
+ private var cancellable: AnyCancellable?
+
+ init() {
+ cancellable = PaykitAllowanceExecutor.eventPublisher.sink { [weak self] event in
+ self?.append(event)
+ }
+ }
+
+ var events: [PaykitAllowanceEvent] {
+ lock.withLock { storage }
+ }
+
+ private func append(_ event: PaykitAllowanceEvent) {
+ lock.withLock { storage.append(event) }
+ }
+}
+
+/// A latch a test holds closed to stop a mocked payment mid-send.
+private final class AllowanceGate: @unchecked Sendable {
+ private let lock = NSLock()
+ private var isOpen = false
+ private var waiters: [CheckedContinuation] = []
+
+ func wait() async {
+ await withCheckedContinuation { continuation in
+ lock.lock()
+ if isOpen {
+ lock.unlock()
+ continuation.resume()
+ } else {
+ waiters.append(continuation)
+ lock.unlock()
+ }
+ }
+ }
+
+ func open() {
+ lock.lock()
+ isOpen = true
+ let pending = waiters
+ waiters = []
+ lock.unlock()
+ pending.forEach { $0.resume() }
+ }
+}
+
+private enum AllowanceMockError: Error {
+ case unsupported
+}
+
+private actor AllowanceLightningLookupMock: PaykitLightningPaymentProofLookingUp {
+ private let log: AllowanceCallLog
+ private var statuses: [String: PaykitLightningPaymentProofStatus] = [:]
+
+ init(log: AllowanceCallLog) {
+ self.log = log
+ }
+
+ func setStatuses(_ statuses: [String: PaykitLightningPaymentProofStatus]) {
+ self.statuses = statuses
+ }
+
+ func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus {
+ log.append("lightningStatus")
+ return statuses[paymentHash.lowercased()] ?? .unknown
+ }
+}
+
+private actor AllowancePayerMock: PaykitAllowancePaying {
+ struct PreparedProof: Equatable {
+ let appId: String
+ let endpoint: String
+ let allowanceId: String?
+ }
+
+ struct LightningPayment: Equatable {
+ let bolt11: String
+ let sats: UInt64?
+ }
+
+ private let log: AllowanceCallLog
+ private let payment: PrivatePaykitAllowancePayment?
+ private var resolveError: Error?
+ private var claimError: Error?
+ private var lightningError: Error?
+ private var lightningGate: AllowanceGate?
+ private var lightningErrorIsDefinite = true
+ private(set) var callCount = 0
+ private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = []
+ private(set) var acceptanceIntents: [PaykitPaymentRequest.ID] = []
+ private(set) var paymentListOutcomes: [PrivatePaymentListSendOutcome] = []
+ private(set) var preparedProofs: [PreparedProof] = []
+ private(set) var associatedPaymentHashes: [String] = []
+ private(set) var lightningPayments: [LightningPayment] = []
+
+ init(log: AllowanceCallLog, payment: PrivatePaykitAllowancePayment?) {
+ self.log = log
+ self.payment = payment
+ }
+
+ private func called(_ name: String) {
+ callCount += 1
+ log.append(name)
+ }
+
+ func setResolveError(_ error: Error?) {
+ resolveError = error
+ }
+
+ func setClaimError(_ error: Error?) {
+ claimError = error
+ }
+
+ func setLightningError(_ error: Error?) {
+ lightningError = error
+ }
+
+ func setLightningErrorIsDefinite(_ definite: Bool) {
+ lightningErrorIsDefinite = definite
+ }
+
+ /// Holds `payLightning` until the returned gate opens, so a test can act while the send is in progress.
+ func holdLightning() -> AllowanceGate {
+ let gate = AllowanceGate()
+ lightningGate = gate
+ return gate
+ }
+
+ func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? {
+ called("resolve")
+ if let resolveError {
+ throw resolveError
+ }
+ return payment
+ }
+
+ func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws {
+ called("consumePaymentList")
+ }
+
+ func resolvePaymentList(
+ publicKey: String,
+ context: PrivatePaykitPaymentContext,
+ attemptId: UUID,
+ outcome: PrivatePaymentListSendOutcome
+ ) async {
+ called("resolvePaymentList")
+ paymentListOutcomes.append(outcome)
+ }
+
+ struct ReleasedList: Equatable {
+ let publicKey: String
+ let version: UInt64
+ let previousVersion: UInt64?
+ }
+
+ private(set) var releasedLists: [ReleasedList] = []
+
+ func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? {
+ nil
+ }
+
+ private var releaseGate: AllowanceGate?
+
+ /// Holds the next list release until the returned gate opens.
+ func holdNextRelease() -> AllowanceGate {
+ let gate = AllowanceGate()
+ releaseGate = gate
+ return gate
+ }
+
+ func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async {
+ called("releaseConsumedPaymentList")
+ releasedLists.append(ReleasedList(publicKey: publicKey, version: version, previousVersion: previousVersion))
+ if let gate = releaseGate {
+ releaseGate = nil
+ await gate.wait()
+ }
+ }
+
+ func claimForExecution(_ request: PaykitPaymentRequest) async throws {
+ called("claimForExecution")
+ if let claimError {
+ throw claimError
+ }
+ claimedRequestIds.append(request.id)
+ }
+
+ func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool {
+ called("recordAcceptanceIntent")
+ guard !acceptanceIntents.contains(request.id) else { return false }
+ acceptanceIntents.append(request.id)
+ return true
+ }
+
+ func seedAcceptanceIntent(_ id: PaykitPaymentRequest.ID) {
+ acceptanceIntents.append(id)
+ }
+
+ func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async {
+ called("discardAcceptanceIntent")
+ acceptanceIntents.removeAll { $0 == request.id }
+ }
+
+ func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws {
+ called("ensurePaymentAllowed")
+ }
+
+ func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws {
+ called("prepareProof")
+ preparedProofs.append(PreparedProof(appId: paymentAppId, endpoint: paymentEndpointIdentifier, allowanceId: allowanceId))
+ }
+
+ func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws {
+ called("associateLightningPayment")
+ associatedPaymentHashes.append(paymentHash)
+ }
+
+ func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws {
+ called("payLightning")
+ lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats))
+ if let lightningGate {
+ await lightningGate.wait()
+ // The real node call is cancellable, so a cancelled caller must not be able to stop the payment here.
+ try Task.checkCancellation()
+ }
+ if let lightningError {
+ throw lightningError
+ }
+ }
+
+ func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool {
+ called("failLightningPayment")
+ return lightningErrorIsDefinite
+ }
+
+ func cancelProofPreparation(_ request: PaykitPaymentRequest) async {
+ called("cancelProofPreparation")
+ }
+}
+
+private actor AllowanceSdkMock: PaykitAllowanceSdkHandling {
+ static let automaticAttemptId = "attempt-1"
+ static let manualAttemptId = "manual-1"
+
+ private let log: AllowanceCallLog
+ struct Proposal {
+ let counterparty: String
+ let localRole: Paykit.AllowanceLocalRole
+ let terms: Paykit.AllowanceTerms
+ }
+
+ private var records: [Paykit.AllowanceRecord] = []
+ private var peers: [LinkedPeerRecord] = []
+ private var accountingState: Paykit.AllowanceAccountingState? = PaykitAllowanceFixtures.accountingState()
+ private var candidates: [Paykit.AllowanceCandidate] = [AllowanceHarness.candidate()]
+ private var automaticReservation: Paykit.PaymentAttemptDecision?
+ private var manualReservation: Paykit.PaymentAttemptDecision?
+ private var beginDecision: Paykit.PaymentAttemptDecision?
+ private var beginError: Error?
+ private var acceptError: Error?
+ private(set) var evaluatedTrustedTimes: [String] = []
+ private(set) var selections: [Paykit.AllowanceSelectionInput] = []
+ private(set) var acceptedEndpointIdentifiers: [String] = []
+ private(set) var reservedAssociationRevisions: [UInt64] = []
+ private(set) var recordedOutcomes: [Paykit.PaymentOutcomeReport] = []
+ private(set) var reconciliations: [Paykit.AllowanceAccountingReconciliation] = []
+ private(set) var manualOnlyOccurrences: [Paykit.PaymentOccurrence] = []
+ private(set) var proposals: [Proposal] = []
+ private(set) var acceptedCounterparties: [String] = []
+
+ init(log: AllowanceCallLog) {
+ self.log = log
+ }
+
+ func setRecords(_ records: [Paykit.AllowanceRecord]) {
+ self.records = records
+ }
+
+ func setPeers(_ peers: [LinkedPeerRecord]) {
+ self.peers = peers
+ }
+
+ func setAccountingState(_ state: Paykit.AllowanceAccountingState?) {
+ accountingState = state
+ }
+
+ func setCandidates(_ candidates: [Paykit.AllowanceCandidate]) {
+ self.candidates = candidates
+ }
+
+ func setAutomaticReservation(_ decision: Paykit.PaymentAttemptDecision) {
+ automaticReservation = decision
+ }
+
+ func setManualReservation(_ decision: Paykit.PaymentAttemptDecision) {
+ manualReservation = decision
+ }
+
+ func setBeginDecision(_ decision: Paykit.PaymentAttemptDecision) {
+ beginDecision = decision
+ }
+
+ func setAcceptError(_ error: Error?) {
+ acceptError = error
+ }
+
+ func setBeginError(_ error: Error?) {
+ beginError = error
+ }
+
+ func linkedPeers() async throws -> [LinkedPeerRecord] {
+ log.append("linkedPeers")
+ return peers
+ }
+
+ func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] {
+ log.append("listAllowances")
+ return records
+ }
+
+ func proposeAllowance(
+ counterparty: String,
+ localRole: Paykit.AllowanceLocalRole,
+ terms: Paykit.AllowanceTerms
+ ) async throws -> Paykit.AllowanceRecord {
+ log.append("proposeAllowance")
+ proposals.append(Proposal(counterparty: counterparty, localRole: localRole, terms: terms))
+ let record = PaykitAllowanceFixtures.record(
+ allowanceId: "proposed-\(proposals.count)",
+ counterparty: counterparty,
+ state: .proposed,
+ terms: terms
+ )
+ records.append(record)
+ return record
+ }
+
+ func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord {
+ log.append("acceptAllowance")
+ acceptedCounterparties.append(counterparty)
+ guard let record = records.first(where: { $0.counterparty == counterparty && $0.allowanceId == allowanceId }) else {
+ throw AllowanceMockError.unsupported
+ }
+ return record
+ }
+
+ func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord {
+ log.append("rejectAllowance")
+ throw AllowanceMockError.unsupported
+ }
+
+ func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord {
+ log.append("endAllowance")
+ throw AllowanceMockError.unsupported
+ }
+
+ func receivePrivateMessages(counterparty: String) async throws -> Paykit.PrivateStreamIntakeReport {
+ log.append("receivePrivateMessages")
+ return Paykit.PrivateStreamIntakeReport(receiveBatchId: nil, streamItemIds: [], eventConflicts: [])
+ }
+
+ func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport {
+ log.append("processOutboundPrivateMessages")
+ return Paykit.OutboundPrivateSendReport(attempted: [], sent: [], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: [])
+ }
+
+ func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? {
+ log.append("allowanceAccountingState")
+ return accountingState
+ }
+
+ func reconcileAllowanceAccounting(_ reconciliation: Paykit.AllowanceAccountingReconciliation) async throws -> Paykit.AllowanceAccountingState {
+ log.append("reconcileAllowanceAccounting")
+ reconciliations.append(reconciliation)
+ let reconciled = Paykit.AllowanceAccountingState(
+ revision: (reconciliation.expectedRevision ?? 0) + 1,
+ epoch: accountingState?.epoch ?? "epoch-1",
+ requiresReconciliation: false,
+ history: reconciliation.history
+ )
+ accountingState = reconciled
+ return reconciled
+ }
+
+ func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] {
+ log.append("evaluateAllowanceCandidates")
+ evaluatedTrustedTimes.append(trustedTime)
+ return candidates
+ }
+
+ func acceptPaymentRequestAutomatically(
+ scope: Paykit.PaymentRequestScope,
+ selection: Paykit.AllowanceSelectionInput,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.AllowanceAssociationRecord {
+ log.append("acceptPaymentRequestAutomatically")
+ if let acceptError {
+ throw acceptError
+ }
+ selections.append(selection)
+ acceptedEndpointIdentifiers.append(checks.paymentEndpointIdentifier)
+ return Paykit.AllowanceAssociationRecord(
+ request: PaykitAllowanceFixtures.accountingScope(scope.paymentRequestId),
+ revisions: [
+ Paykit.AllowanceAssociationRevision(
+ revision: 1,
+ allowanceId: selection.allowanceId,
+ effectiveFrom: nil,
+ authorizationId: nil,
+ authorizedAt: selection.trustedTime
+ ),
+ ]
+ )
+ }
+
+ func reserveAutomaticPayment(
+ occurrence: Paykit.PaymentOccurrence,
+ expectedAssociationRevision: UInt64,
+ checks: Paykit.PaymentExecutionChecks
+ ) async throws -> Paykit.PaymentAttemptDecision {
+ log.append("reserveAutomaticPayment")
+ reservedAssociationRevisions.append(expectedAssociationRevision)
+ if let automaticReservation {
+ return automaticReservation
+ }
+ return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(
+ id: Self.automaticAttemptId,
+ admittedAt: checks.trustedTime,
+ status: .prepared
+ ))
+ }
+
+ func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit
+ .PaymentAttemptDecision
+ {
+ log.append("reserveManualPayment")
+ if let manualReservation {
+ return manualReservation
+ }
+ return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(
+ id: Self.manualAttemptId,
+ mode: .manual,
+ allowanceId: nil,
+ admittedAt: checks.trustedTime,
+ status: .prepared
+ ))
+ }
+
+ func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision {
+ log.append("beginPaymentExecution")
+ if let beginError {
+ throw beginError
+ }
+ if let beginDecision {
+ return beginDecision
+ }
+ return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(id: attemptId, admittedAt: checks.trustedTime, status: .submitted))
+ }
+
+ func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord {
+ log.append("recordPaymentOutcome")
+ recordedOutcomes.append(report)
+ let status: Paykit.PaymentExecutionStatus = switch report.outcome {
+ case .succeeded: .succeeded
+ case .failed: .failed
+ case .unknown: .unknown
+ }
+ return try PaykitAllowanceFixtures.attemptRecord(id: report.attemptId, status: status)
+ }
+
+ func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord {
+ log.append("markPaymentManualOnly")
+ manualOnlyOccurrences.append(occurrence)
+ return Paykit.PaymentOccurrenceRecord(
+ key: Paykit.PaymentOccurrenceKey(
+ request: PaykitAllowanceFixtures.accountingScope(occurrence.request.paymentRequestId),
+ billingPeriod: nil
+ ),
+ disposition: .manualOnly,
+ allowanceId: nil,
+ associationRevision: nil,
+ attempts: []
+ )
+ }
+}
diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift
new file mode 100644
index 000000000..a43372fb8
--- /dev/null
+++ b/BitkitTests/PaykitAllowanceTests.swift
@@ -0,0 +1,632 @@
+@testable import Bitkit
+import Foundation
+import LDKNode
+import Paykit
+import XCTest
+
+final class PaykitAllowanceTests: XCTestCase {
+ private typealias Fixtures = PaykitAllowanceFixtures
+
+ // MARK: Terms and records
+
+ func testTermsCarryPerPaymentRangeAnchoredUtcMonthAndAllowlist() throws {
+ let monthAnchor = PaykitAllowanceTime.monthStart(containing: Fixtures.now)
+ let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier]
+
+ let terms = try Fixtures.limits.terms(monthAnchor: monthAnchor, allowedPaymentEndpointIdentifiers: allowlist)
+
+ XCTAssertEqual(terms.asset(), PaykitIssuerInterop.bitcoinAsset)
+ let perPayment = try XCTUnwrap(terms.perPaymentAmount())
+ XCTAssertEqual(perPayment.minimum(), "0")
+ XCTAssertEqual(perPayment.maximum(), "0.00005")
+ XCTAssertEqual(terms.periodLimits().count, 1)
+ let monthly = try XCTUnwrap(terms.periodLimits().first)
+ XCTAssertEqual(monthly.amountLimit(), "0.0005")
+ XCTAssertNil(monthly.paymentCountLimit())
+ XCTAssertEqual(monthly.period().kind(), "anchored")
+ XCTAssertEqual(monthly.period().every(), 1)
+ XCTAssertEqual(monthly.period().unit(), "month")
+ XCTAssertTrue(PaykitAllowance.isMonthly(monthly.period()))
+ XCTAssertEqual(monthly.period().anchor(), "2026-09-01T00:00:00.000Z")
+ XCTAssertEqual(monthly.period().anchor().flatMap(PaykitAllowanceTime.parse), Fixtures.septemberAnchor)
+ XCTAssertNil(terms.lifetimeAmountLimit())
+ XCTAssertNil(terms.activeFrom())
+ XCTAssertNil(terms.expiresAt())
+ XCTAssertEqual(terms.allowedPaymentEndpointIdentifiers(), allowlist)
+ }
+
+ func testRecordReadsBackSatsAnchorRoleAndAllowlist() throws {
+ let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier]
+ let terms = try Fixtures.limits.terms(monthAnchor: Fixtures.septemberAnchor, allowedPaymentEndpointIdentifiers: allowlist)
+ let record = Fixtures.record(state: .proposed, terms: terms, proposedByMe: true)
+
+ let allowance = try XCTUnwrap(PaykitAllowance(record: record))
+
+ XCTAssertEqual(allowance.counterparty, Fixtures.counterpartyKey)
+ XCTAssertEqual(allowance.allowanceId, Fixtures.walletAllowanceId)
+ XCTAssertEqual(allowance.role, .allower)
+ XCTAssertTrue(allowance.isAllower)
+ XCTAssertTrue(allowance.isProposedByMe)
+ XCTAssertEqual(allowance.lifecycleState, .proposed)
+ XCTAssertEqual(allowance.perPaymentMaxSats, 5000)
+ XCTAssertEqual(allowance.monthlyLimitSats, 50000)
+ XCTAssertEqual(allowance.monthlyAnchor, Fixtures.septemberAnchor)
+ XCTAssertNil(allowance.activeFrom)
+ XCTAssertNil(allowance.expiresAt)
+ XCTAssertEqual(allowance.allowedPaymentEndpointIdentifiers, allowlist)
+ XCTAssertEqual(allowance.lastEventAt, Fixtures.utc("2026-09-02T10:00:00Z"))
+
+ let receivedRecord = Fixtures.record(localRole: .allowee, state: .proposed, terms: terms, proposedByMe: false)
+ let received = try XCTUnwrap(PaykitAllowance(record: receivedRecord))
+ XCTAssertEqual(received.role, .allowee)
+ XCTAssertFalse(received.isAllower)
+ XCTAssertFalse(received.isProposedByMe)
+ XCTAssertTrue(received.isAnswerable)
+ }
+
+ func testRecordWithoutUsableRoleOrTermsIsSkipped() throws {
+ let terms = try Fixtures.standardTerms()
+
+ XCTAssertNil(PaykitAllowance(record: Fixtures.record(localRole: nil, terms: terms)))
+ XCTAssertNil(PaykitAllowance(record: Fixtures.record(localRole: .unknown, terms: terms)))
+ XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: nil)))
+ }
+
+ func testStatusMapsEveryLifecycleState() throws {
+ func status(
+ _ state: Paykit.AllowanceLifecycleState,
+ proposedByMe: Bool = true,
+ terms: Paykit.AllowanceTerms? = nil,
+ at date: Date = PaykitAllowanceFixtures.now
+ ) throws -> PaykitAllowance.Status {
+ let resolvedTerms = try terms ?? Fixtures.standardTerms()
+ let record = Fixtures.record(state: state, terms: resolvedTerms, proposedByMe: proposedByMe)
+ return try XCTUnwrap(PaykitAllowance(record: record)).status(at: date)
+ }
+
+ XCTAssertEqual(try status(.proposed, proposedByMe: true), .awaitingAnswer)
+ XCTAssertEqual(try status(.proposed, proposedByMe: false), .awaitingMyAnswer)
+ XCTAssertEqual(try status(.accepted), .active)
+ XCTAssertEqual(try status(.rejected), .declined)
+ XCTAssertEqual(try status(.ended), .ended)
+ XCTAssertEqual(try status(.conflicted), .conflicted)
+ XCTAssertEqual(try status(.unknown), .conflicted)
+
+ let expiry = Fixtures.utc("2026-09-20T00:00:00Z")
+ let expiring = try Fixtures.customTerms(expiresAt: expiry)
+ XCTAssertEqual(try status(.accepted, terms: expiring, at: expiry.addingTimeInterval(-1)), .active)
+ XCTAssertEqual(try status(.accepted, terms: expiring, at: expiry), .expired)
+ XCTAssertEqual(try status(.accepted, terms: expiring, at: Fixtures.now), .expired)
+
+ let start = Fixtures.utc("2026-10-01T00:00:00Z")
+ let scheduled = try Fixtures.customTerms(activeFrom: start)
+ XCTAssertEqual(try status(.accepted, terms: scheduled, at: Fixtures.now), .notYetActive)
+ XCTAssertEqual(try status(.accepted, terms: scheduled, at: start), .active)
+ }
+
+ func testSatsFromBitcoinAmountReadsTheZeroMinimum() {
+ XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0"), 0)
+ XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.00000000"), 0)
+ XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.00005"), 5000)
+ XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.0005"), 50000)
+ XCTAssertNil(PaykitAllowance.sats(fromBitcoinAmount: "abc"))
+ }
+
+ // MARK: Monthly window
+
+ func testMonthStartUsesTheUtcCalendarMonth() {
+ XCTAssertEqual(PaykitAllowanceTime.monthStart(containing: Fixtures.now), Fixtures.septemberAnchor)
+ XCTAssertEqual(PaykitAllowanceTime.monthStart(containing: Fixtures.utc("2026-10-01T01:00:00+02:00")), Fixtures.septemberAnchor)
+ XCTAssertEqual(
+ PaykitAllowanceTime.monthStart(containing: Fixtures.utc("2026-09-30T23:30:00-02:00")),
+ Fixtures.utc("2026-10-01T00:00:00Z")
+ )
+ }
+
+ func testMonthlyWindowFromFirstOfMonthAnchor() {
+ let anchor = Fixtures.septemberAnchor
+ let cases: [(date: String, start: String, end: String)] = [
+ ("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"),
+ ("2026-09-24T12:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"),
+ ("2026-09-30T23:59:59Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"),
+ ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z"),
+ ("2026-12-31T23:59:59Z", "2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"),
+ ("2027-02-10T08:00:00Z", "2027-02-01T00:00:00Z", "2027-03-01T00:00:00Z"),
+ ("2026-08-31T23:59:59Z", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"),
+ ("2026-07-15T12:00:00Z", "2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"),
+ ]
+
+ for testCase in cases {
+ let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc(testCase.date))
+ XCTAssertEqual(window.start, Fixtures.utc(testCase.start), "start for \(testCase.date)")
+ XCTAssertEqual(window.end, Fixtures.utc(testCase.end), "end for \(testCase.date)")
+ }
+ }
+
+ func testMonthlyWindowClampsJanuaryThirtyFirstAnchorInFebruary() {
+ let anchor = Fixtures.utc("2026-01-31T12:30:00Z")
+
+ let midFebruary = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc("2026-02-15T00:00:00Z"))
+ XCTAssertEqual(midFebruary.start, anchor)
+ XCTAssertEqual(midFebruary.end, Fixtures.utc("2026-02-28T12:30:00Z"))
+
+ let lateFebruary = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc("2026-02-28T12:30:00Z"))
+ XCTAssertEqual(lateFebruary.start, Fixtures.utc("2026-02-28T12:30:00Z"))
+ }
+
+ /// Vectors from paykit-lib `test_anchored_months_clamp_from_original_anchor`: every boundary is counted from the
+ /// original anchor, so the window after a clamped February still ends on March 31.
+ func testMonthlyWindowAfterClampedFebruaryMatchesPaykitAnchoredArithmetic() {
+ let anchor = Fixtures.utc("2026-01-31T12:30:00Z")
+ let vectors: [(date: String, start: String, end: String)] = [
+ ("2026-02-28T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"),
+ ("2026-03-30T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"),
+ ("2025-12-01T00:00:00Z", "2025-11-30T12:30:00Z", "2025-12-31T12:30:00Z"),
+ ]
+ let marchAnchor = Fixtures.utc("2026-03-31T00:00:00Z")
+ let beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow(anchor: marchAnchor, containing: Fixtures.utc("2026-01-15T00:00:00Z"))
+ let allowance = Fixtures.allowance(monthlyAnchor: anchor)
+ let lateMarchAttempt = PaykitAllowanceCapacity.Attempt(
+ counterparty: Fixtures.counterpartyKey,
+ allowanceId: allowance.allowanceId,
+ amountSats: 50000,
+ admittedAt: Fixtures.utc("2026-03-29T09:00:00Z"),
+ isLive: true
+ )
+
+ for vector in vectors {
+ let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc(vector.date))
+ XCTAssertEqual(window.start, Fixtures.utc(vector.start), "start for \(vector.date)")
+ XCTAssertEqual(window.end, Fixtures.utc(vector.end), "end for \(vector.date)")
+ }
+ XCTAssertEqual(beforeMarchAnchor.start, Fixtures.utc("2025-12-31T00:00:00Z"), "start before a March 31 anchor")
+ XCTAssertEqual(beforeMarchAnchor.end, Fixtures.utc("2026-01-31T00:00:00Z"), "end before a March 31 anchor")
+ XCTAssertFalse(
+ PaykitAllowanceCapacity.fits(
+ amountSats: 1000,
+ allowance: allowance,
+ attempts: [lateMarchAttempt],
+ now: Fixtures.utc("2026-03-30T12:30:00Z")
+ ),
+ "A March 29 attempt at the cap must count on March 30"
+ )
+ }
+
+ // MARK: Capacity
+
+ func testCapacityFitsUnderTheCap() {
+ let attempts = [Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z")]
+
+ XCTAssertEqual(Fixtures.usedSats(attempts), 20000)
+ XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now))
+ }
+
+ func testCapacityFitsExactlyAtTheCap() {
+ let attempts = [
+ Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z"),
+ Fixtures.capacityAttempt(sats: 25000, at: "2026-09-12T10:00:00Z"),
+ ]
+
+ XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now))
+ }
+
+ func testCapacityRejectsOverTheMonthlyCap() {
+ let attempts = [
+ Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z"),
+ Fixtures.capacityAttempt(sats: 25000, at: "2026-09-12T10:00:00Z"),
+ ]
+
+ XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5001, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now))
+ }
+
+ func testCapacityRejectsOverThePerPaymentMaximum() {
+ let allowance = Fixtures.allowance()
+
+ XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: allowance, attempts: [], now: Fixtures.now))
+ XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5001, allowance: allowance, attempts: [], now: Fixtures.now))
+ }
+
+ func testCapacityIgnoresFailedAttempts() {
+ let attempts = [
+ Fixtures.capacityAttempt(sats: 45000, at: "2026-09-05T10:00:00Z", isLive: false),
+ Fixtures.capacityAttempt(sats: 10000, at: "2026-09-12T10:00:00Z"),
+ ]
+
+ XCTAssertEqual(Fixtures.usedSats(attempts), 10000)
+ XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now))
+ }
+
+ func testCapacityIgnoresPreviousMonthAndOtherAllowanceAttempts() {
+ let attempts = [
+ Fixtures.capacityAttempt(sats: 50000, at: "2026-08-31T23:59:59Z"),
+ Fixtures.capacityAttempt(allowanceId: Fixtures.otherAllowanceId, sats: 50000, at: "2026-09-10T10:00:00Z"),
+ Fixtures.capacityAttempt(sats: 1000, at: "2026-09-01T00:00:00Z"),
+ ]
+
+ XCTAssertEqual(Fixtures.usedSats(attempts), 1000)
+ XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now))
+ }
+
+ func testAnotherContactsAttemptsUnderTheSameIdNeverFillThisContactsCap() {
+ let attempts = [
+ Fixtures.capacityAttempt(counterparty: Fixtures.otherCounterpartyKey, sats: 45000, at: "2026-09-05T10:00:00Z"),
+ Fixtures.capacityAttempt(sats: 1000, at: "2026-09-06T10:00:00Z"),
+ ]
+
+ XCTAssertEqual(Fixtures.usedSats(attempts), 1000)
+ XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now))
+ }
+
+ func testAllowerTermsWithADustPerPaymentMaximumAreDropped() throws {
+ let terms = try Paykit.AllowanceTerms(
+ asset: PaykitIssuerInterop.bitcoinAsset,
+ perPaymentAmount: Paykit.AllowanceAmountRange(minimum: "0", maximum: "0.000000001"),
+ periodLimits: [
+ Paykit.AllowancePeriodLimit(
+ amountLimit: "0.0005",
+ paymentCountLimit: nil,
+ period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-01T00:00:00Z")
+ ),
+ ],
+ lifetimeAmountLimit: nil,
+ activeFrom: nil,
+ expiresAt: nil,
+ allowedPaymentEndpointIdentifiers: [Fixtures.lightningIdentifier]
+ )
+
+ XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: terms, proposedByMe: false)))
+ XCTAssertNotNil(PaykitAllowance(record: Fixtures.record(localRole: .allowee, terms: terms, proposedByMe: false)))
+ }
+
+ func testAutomaticLightningRoutingFeeIsCappedAtOnePercent() {
+ XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1000).maxTotalRoutingFeeMsat, 10000)
+ XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1).maxTotalRoutingFeeMsat, 10)
+ }
+
+ func testCapacityWithoutBothCeilingsNeverFits() {
+ XCTAssertFalse(PaykitAllowanceCapacity.fits(
+ amountSats: 5000,
+ allowance: Fixtures.allowance(monthlyLimitSats: nil),
+ attempts: [],
+ now: Fixtures.now
+ ))
+ XCTAssertFalse(PaykitAllowanceCapacity.fits(
+ amountSats: 5000,
+ allowance: Fixtures.allowance(perPaymentMaxSats: nil),
+ attempts: [],
+ now: Fixtures.now
+ ))
+ XCTAssertFalse(PaykitAllowanceCapacity.fits(
+ amountSats: 5000,
+ allowance: Fixtures.allowance(monthlyAnchor: nil),
+ attempts: [],
+ now: Fixtures.now
+ ))
+ }
+
+ func testAttemptsFromHistoryKeepAutomaticAllowanceAttempts() throws {
+ let history = try Paykit.AllowanceAccountingHistory(
+ associations: [],
+ occurrences: [
+ Fixtures.occurrence(requestId: "req-1", attempts: [
+ Fixtures.attemptRecord(id: "failed", amount: "0.0001", admittedAt: "2026-09-02T10:00:00Z", status: .failed),
+ Fixtures.attemptRecord(id: "paid", amount: "0.0001", admittedAt: "2026-09-03T10:00:00Z", status: .succeeded),
+ ]),
+ Fixtures.occurrence(requestId: "req-2", attempts: [
+ Fixtures.attemptRecord(id: "manual", mode: .manual, allowanceId: nil, admittedAt: "2026-09-04T10:00:00Z", status: .succeeded),
+ Fixtures.attemptRecord(id: "unattributed", allowanceId: nil, admittedAt: "2026-09-04T11:00:00Z", status: .succeeded),
+ Fixtures.attemptRecord(id: "open", amount: "0.00002", admittedAt: "2026-09-05T10:00:00Z", status: .submitted),
+ ]),
+ ],
+ watermarks: []
+ )
+
+ let attempts = PaykitAllowanceCapacity.attempts(from: history)
+
+ XCTAssertEqual(attempts, [
+ PaykitAllowanceCapacity.Attempt(
+ counterparty: Fixtures.counterpartyKey,
+ allowanceId: Fixtures.walletAllowanceId,
+ amountSats: 10000,
+ admittedAt: Fixtures.utc("2026-09-02T10:00:00Z"),
+ isLive: false
+ ),
+ PaykitAllowanceCapacity.Attempt(
+ counterparty: Fixtures.counterpartyKey,
+ allowanceId: Fixtures.walletAllowanceId,
+ amountSats: 10000,
+ admittedAt: Fixtures.utc("2026-09-03T10:00:00Z"),
+ isLive: true
+ ),
+ PaykitAllowanceCapacity.Attempt(
+ counterparty: Fixtures.counterpartyKey,
+ allowanceId: Fixtures.walletAllowanceId,
+ amountSats: 2000,
+ admittedAt: Fixtures.utc("2026-09-05T10:00:00Z"),
+ isLive: true
+ ),
+ ])
+ }
+
+ // MARK: Trusted time vs subscription clock offset
+
+ func testStatusAndCapacityUseInjectedTimeWhileSubscriptionClockIsOffset() throws {
+ snapshotAppDefaults(SubscriptionClock.offsetDaysKey)
+ UserDefaults.standard.set(365, forKey: SubscriptionClock.offsetDaysKey)
+ try XCTSkipUnless(SubscriptionClock.offsetDays() == 365, "The subscription clock offset is unavailable in this build")
+ let realNow = Date()
+ XCTAssertGreaterThan(SubscriptionClock.subscriptionNow(), realNow.addingTimeInterval(364 * 24 * 60 * 60))
+
+ let allowance = Fixtures.allowance(expiresAt: Fixtures.now.addingTimeInterval(30 * 24 * 60 * 60))
+ XCTAssertEqual(allowance.status(at: Fixtures.now), .active)
+
+ let attempts = [Fixtures.capacityAttempt(sats: 50000, at: "2026-09-10T10:00:00Z")]
+ XCTAssertEqual(Fixtures.usedSats(attempts), 50000)
+ XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 1, allowance: allowance, attempts: attempts, now: Fixtures.now))
+ }
+
+ // MARK: Grouping
+
+ func testEntryPrimaryIsTheGrantsAllowance() {
+ let allowance = Fixtures.allowance(allowanceId: Fixtures.walletAllowanceId)
+
+ let entry = PaykitAllowanceEntry(id: "group", allowances: [allowance], limits: Fixtures.limits)
+
+ XCTAssertEqual(entry.primary.allowanceId, Fixtures.walletAllowanceId)
+ XCTAssertEqual(entry.counterparty, Fixtures.counterpartyKey)
+ XCTAssertEqual(entry.perPaymentMaxSats, 5000)
+ XCTAssertEqual(entry.monthlyLimitSats, 50000)
+ XCTAssertEqual(entry.limits, Fixtures.limits)
+ }
+}
+
+/// Shared builders for the Allowance suites.
+enum PaykitAllowanceFixtures {
+ static let identityKey = "pubky\(String(repeating: "z", count: 52))"
+ static let counterpartyKey = "pubky\(String(repeating: "y", count: 52))"
+ static let otherCounterpartyKey = "pubky\(String(repeating: "x", count: 52))"
+ static let walletAllowanceId = "allowance-wallet"
+ static let otherAllowanceId = "allowance-other"
+ static let lightningIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue
+ static let onchainIdentifier = PublicPaykitService.MethodId.bitcoinOnchainP2wpkh.rawValue
+ static let now = utc("2026-09-24T12:00:00Z")
+ static let septemberAnchor = utc("2026-09-01T00:00:00Z")
+ static let limits = PaykitAllowanceLimits(perPaymentUsd: 5, monthlyUsd: 50, perPaymentSats: 5000, monthlySats: 50000)
+
+ static func utc(_ value: String) -> Date {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ guard let date = formatter.date(from: value) else {
+ preconditionFailure("Invalid fixture timestamp \(value)")
+ }
+ return date
+ }
+
+ static func standardTerms() throws -> Paykit.AllowanceTerms {
+ try limits.terms(monthAnchor: septemberAnchor, allowedPaymentEndpointIdentifiers: [lightningIdentifier])
+ }
+
+ static func customTerms(activeFrom: Date? = nil, expiresAt: Date? = nil) throws -> Paykit.AllowanceTerms {
+ try Paykit.AllowanceTerms(
+ asset: PaykitIssuerInterop.bitcoinAsset,
+ perPaymentAmount: Paykit.AllowanceAmountRange(minimum: "0", maximum: "0.00005"),
+ periodLimits: [
+ Paykit.AllowancePeriodLimit(
+ amountLimit: "0.0005",
+ paymentCountLimit: nil,
+ period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-01T00:00:00Z")
+ ),
+ ],
+ lifetimeAmountLimit: nil,
+ activeFrom: activeFrom.map(PaykitAllowanceTime.format),
+ expiresAt: expiresAt.map(PaykitAllowanceTime.format),
+ allowedPaymentEndpointIdentifiers: [lightningIdentifier]
+ )
+ }
+
+ static func record(
+ allowanceId: String = walletAllowanceId,
+ counterparty: String = counterpartyKey,
+ localRole: Paykit.AllowanceLocalRole? = .allower,
+ state: Paykit.AllowanceLifecycleState = .accepted,
+ historyStatus: Paykit.AllowanceHistoryStatus = .consistent,
+ terms: Paykit.AllowanceTerms?,
+ proposedByMe: Bool = true,
+ lastEventAt: String? = "2026-09-02T10:00:00Z"
+ ) -> Paykit.AllowanceRecord {
+ Paykit.AllowanceRecord(
+ counterparty: counterparty,
+ allowanceId: allowanceId,
+ localRole: localRole,
+ state: state,
+ historyStatus: historyStatus,
+ proposalEventId: "proposal-\(allowanceId)",
+ terms: terms,
+ proposalStreamItemId: proposedByMe ? nil : 1,
+ proposalOutboundMessageId: proposedByMe ? 1 : nil,
+ proposalOutboundStatus: nil,
+ acceptanceEventId: nil,
+ acceptanceOutboundStatus: nil,
+ rejectionEventId: nil,
+ rejectionOutboundStatus: nil,
+ endEventId: nil,
+ endOutboundStatus: nil,
+ pendingCausalEventIds: [],
+ conflictEventIds: [],
+ lastStreamItemId: nil,
+ lastOutboundMessageId: nil,
+ lastOutboundStatus: nil,
+ lastEventAt: lastEventAt,
+ invalidReason: nil
+ )
+ }
+
+ static func contact(publicKey: String = counterpartyKey) -> PubkyContact {
+ PubkyContact(
+ publicKey: publicKey,
+ profile: PubkyProfile(publicKey: publicKey, name: "Alice", bio: "", imageUrl: nil, links: [], status: nil)
+ )
+ }
+
+ static func linkedPeer(counterparty: String = counterpartyKey, state: Paykit.LinkedPeerState) -> Paykit.LinkedPeerRecord {
+ Paykit.LinkedPeerRecord(
+ counterparty: counterparty,
+ state: state,
+ lastSyncAt: nil,
+ lastPrivateReceiveAt: nil,
+ failureCount: 0,
+ localRecoveryAttemptId: nil,
+ localRecoveryMarkerCreatedAt: nil,
+ localRecoveryMarkerLastError: nil,
+ remoteRecoveryAttemptId: nil,
+ remoteRecoveryMarkerObservedAt: nil
+ )
+ }
+
+ static func allowance(
+ allowanceId: String = walletAllowanceId,
+ counterparty: String = counterpartyKey,
+ role: PaykitAllowance.Role = .allower,
+ state: Paykit.AllowanceLifecycleState = .accepted,
+ perPaymentMaxSats: UInt64? = 5000,
+ monthlyLimitSats: UInt64? = 50000,
+ monthlyAnchor: Date? = septemberAnchor,
+ expiresAt: Date? = nil
+ ) -> PaykitAllowance {
+ PaykitAllowance(
+ id: PaykitAllowance.ID(counterparty: counterparty, allowanceId: allowanceId),
+ role: role,
+ lifecycleState: state,
+ isProposedByMe: role == .allower,
+ perPaymentMaxSats: perPaymentMaxSats,
+ monthlyLimitSats: monthlyLimitSats,
+ monthlyAnchor: monthlyAnchor,
+ expiresAt: expiresAt,
+ allowedPaymentEndpointIdentifiers: [lightningIdentifier]
+ )
+ }
+
+ static func capacityAttempt(
+ allowanceId: String = walletAllowanceId,
+ counterparty: String = counterpartyKey,
+ sats: UInt64,
+ at timestamp: String,
+ isLive: Bool = true
+ ) -> PaykitAllowanceCapacity.Attempt {
+ PaykitAllowanceCapacity.Attempt(
+ counterparty: counterparty, allowanceId: allowanceId, amountSats: sats, admittedAt: utc(timestamp), isLive: isLive
+ )
+ }
+
+ static func usedSats(_ attempts: [PaykitAllowanceCapacity.Attempt]) -> UInt64 {
+ PaykitAllowanceCapacity.usedSats(
+ counterparty: counterpartyKey, allowanceId: walletAllowanceId, attempts: attempts, anchor: septemberAnchor, now: now
+ )
+ }
+
+ static func attemptRecord(
+ id: String,
+ mode: Paykit.PaymentExecutionMode = .automatic,
+ allowanceId: String? = walletAllowanceId,
+ amount: String = "0.00001",
+ admittedAt: String = "2026-09-24T12:00:00Z",
+ status: Paykit.PaymentExecutionStatus,
+ epoch: String = "epoch-1"
+ ) throws -> Paykit.PaymentAttemptRecord {
+ try Paykit.PaymentAttemptRecord(
+ attemptId: id,
+ mode: mode,
+ allowanceId: allowanceId,
+ associationRevision: allowanceId == nil ? nil : 1,
+ amount: Paykit.AccountingAmount(value: amount, asset: PaykitIssuerInterop.bitcoinAsset),
+ admittedAt: admittedAt,
+ status: status,
+ epoch: epoch
+ )
+ }
+
+ static func accountingScope(_ paymentRequestId: String) -> Paykit.PaymentAccountingScope {
+ Paykit.PaymentAccountingScope(
+ localPublicKey: identityKey,
+ counterparty: counterpartyKey,
+ paymentRequestId: paymentRequestId
+ )
+ }
+
+ static func occurrence(
+ requestId: String,
+ attempts: [Paykit.PaymentAttemptRecord],
+ allowanceId: String? = walletAllowanceId
+ ) -> Paykit.PaymentOccurrenceRecord {
+ Paykit.PaymentOccurrenceRecord(
+ key: Paykit.PaymentOccurrenceKey(request: accountingScope(requestId), billingPeriod: nil),
+ disposition: .automatic,
+ allowanceId: allowanceId,
+ associationRevision: allowanceId == nil ? nil : 1,
+ attempts: attempts
+ )
+ }
+
+ static func accountingState(
+ revision: UInt64 = 1,
+ epoch: String = "epoch-1",
+ requiresReconciliation: Bool = false,
+ occurrences: [Paykit.PaymentOccurrenceRecord] = []
+ ) -> Paykit.AllowanceAccountingState {
+ Paykit.AllowanceAccountingState(
+ revision: revision,
+ epoch: epoch,
+ requiresReconciliation: requiresReconciliation,
+ history: Paykit.AllowanceAccountingHistory(associations: [], occurrences: occurrences, watermarks: [])
+ )
+ }
+
+ static func paymentRequest(
+ id: String = "550e8400-e29b-41d4-a716-446655440001",
+ counterparty: String = counterpartyKey,
+ amount: String = "0.00001",
+ asset: String = PaykitIssuerInterop.bitcoinAsset,
+ conversion: Paykit.PaymentConversion? = nil,
+ createdAt: String = "2026-09-24T11:00:00Z"
+ ) throws -> PaykitPaymentRequest {
+ let record = try Paykit.PaymentRequestRecord(
+ counterparty: counterparty,
+ paymentRequestId: id,
+ localRole: .payer,
+ state: .proposed,
+ proposalStreamItemId: 1,
+ proposalOutboundMessageId: nil,
+ proposalOutboundStatus: nil,
+ proposalEventId: "650e8400-e29b-41d4-a716-446655440000",
+ proposalAppId: "bitkit",
+ payerAppId: nil,
+ executionClaimAppId: nil,
+ terms: Paykit.PaymentRequestTerms(
+ amount: Paykit.PaymentRequestAmount(value: amount, asset: asset),
+ paymentReference: Paykit.PaymentReference(text: "invoice-123"),
+ proposalExpiresAt: nil,
+ recurrence: nil,
+ acceptedPaymentEndpointIdentifiers: [lightningIdentifier],
+ paymentEndpoints: nil,
+ requiredAppId: "bitkit",
+ conversion: conversion,
+ paymentDeadline: nil,
+ metadata: Paykit.PrivateJsonObject(text: "{}")
+ ),
+ acceptedEventId: nil,
+ acceptedOutboundStatus: nil,
+ rejectedEventId: nil,
+ rejectedOutboundStatus: nil,
+ canceledEventId: nil,
+ canceledOutboundStatus: nil,
+ conversionQuotes: [],
+ paymentProofs: [],
+ lastStreamItemId: 1,
+ lastOutboundMessageId: nil,
+ lastOutboundStatus: nil,
+ lastEventAt: createdAt,
+ invalidReason: nil
+ )
+ return try XCTUnwrap(PaykitPaymentRequest(record: record, now: now, network: .regtest))
+ }
+}
diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift
index 8b6ef1e67..2cccd9828 100644
--- a/BitkitTests/PaykitPaymentProofServiceTests.swift
+++ b/BitkitTests/PaykitPaymentProofServiceTests.swift
@@ -3401,7 +3401,7 @@ actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling {
billingPeriod: proof.billingPeriod,
paymentAppId: proof.paymentAppId,
paymentEndpointIdentifier: proof.paymentEndpointIdentifier,
- allowanceId: nil,
+ allowanceId: proof.allowanceId,
conversionQuoteId: nil,
proof: proof.proof,
recordedAt: "2027-01-15T08:01:00Z"
diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift
index be6476257..27311740d 100644
--- a/BitkitTests/PaykitPaymentStateBackupTests.swift
+++ b/BitkitTests/PaykitPaymentStateBackupTests.swift
@@ -557,6 +557,28 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
}
}
+ func testAutomaticAllowancePaymentProofKeepsItsAllowanceIdThroughBackup() throws {
+ let proof = PendingPaykitPaymentProof(
+ identity: identity,
+ requestId: PaykitPaymentRequest.ID(paymentRequestId: "allowance-request", counterparty: identity),
+ paymentAppId: "bitkit",
+ paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue,
+ kind: .lightning,
+ allowanceId: "allowance-1",
+ paymentStarted: true,
+ paymentIdentifier: "payment-hash",
+ proofData: nil
+ )
+ let encoded = try JSONEncoder().encode(PaykitPaymentStateBackup.Proof(proof))
+ let restored = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: encoded).restored()
+ XCTAssertEqual(restored.allowanceId, "allowance-1")
+
+ var legacy = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ legacy.removeValue(forKey: "allowanceId")
+ let legacyProof = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: JSONSerialization.data(withJSONObject: legacy))
+ XCTAssertNil(try legacyProof.restored().allowanceId, "Backups written before allowances still restore")
+ }
+
func testHardwareBackupRequiresCompleteSignedReceipt() throws {
let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden())
let proof = try XCTUnwrap(envelope.paykitPaymentState?.pendingProofs.first)
diff --git a/changelog.d/next/799.added.md b/changelog.d/next/799.added.md
new file mode 100644
index 000000000..6e89aeb68
--- /dev/null
+++ b/changelog.d/next/799.added.md
@@ -0,0 +1 @@
+Allowances: set a per-payment and a monthly limit for a Paykit contact so their payment requests within the limits are paid automatically.
diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md
new file mode 100644
index 000000000..6f79efde2
--- /dev/null
+++ b/journeys/allowances/README.md
@@ -0,0 +1,57 @@
+# Allowances journeys
+
+Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a
+contact, the payee accepts it, requests within the limits are paid without asking, a request above a
+limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey
+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.
+
+## Setup
+
+Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as
+contacts and linked, exactly as for [`../subscriptions/README.md`](../subscriptions/README.md). One
+instance plays the payer (the one that sets the allowance), the other the payee (the one that sends
+requests). Automatic payments pay only the payee's private Lightning invoice; a request the payee
+can take only on-chain asks as an ordinary Payment Request. The payer needs a spending balance
+above 50,000 sats with a usable channel, and the payee needs receiving capacity; fund both through
+the staging LSP.
+
+Allow notifications for Bitkit on the payer when it asks: the "Payment Executed" and "Limit
+Reached" events post a local notification when they can, and fall back to an in-app toast that the
+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
+at the current rate, so a sats amount next to them will differ between runs.
+
+## Reference evidence
+
+The source run drove every journey on 2026-09-24 across two Android 16 emulators against the
+staging regtest LSP, with Paykit built from pubky/paykit-rs#161, and the set, accept, auto-pay,
+ask-above-limit and end flows again on two iOS simulators. A $2 and a $4 request were paid
+about two seconds after arriving, a $20 request asked, the third $4 request on a $10 monthly cap
+raised Limit Reached, ending the allowance from either side stopped automatic payments, and a payer
+killed right after handing the payment to the node never paid twice after relaunch.
+
+## Identifiers used
+
+- Tab: `Tab-allowances`; empty state `AllowancesEmpty`; footer button `AllowanceAdd`
+- Contact picker: `AllowanceContact-`
+- Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops
+ `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`,
+ `AllowanceSave`
+- List row: `AllowanceRow-` with its status line `AllowanceRowStatus`; the entry id is the payer's grant id, or the contact key and allowance id on the payee
+- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`,
+ `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline`
+- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus`
+- Payments tab: `Tab-payments`, `PaymentRequestRequestPayment`,
+ `PaymentRequestRow--one-time` whose subtitle ends with "· Auto-paid" for an
+ automatic payment
+- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet`
+
+The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is
+needed to reach it. The file names, journey names and step prose match
+[`bitkit-android/journeys/allowances`](https://github.com/synonymdev/bitkit-android/tree/master/journeys/allowances);
+only the identifier annotations and the kill, relaunch and notification mechanics differ.
diff --git a/journeys/allowances/above-limit-asks.xml b/journeys/allowances/above-limit-asks.xml
new file mode 100644
index 000000000..a24e7ff87
--- /dev/null
+++ b/journeys/allowances/above-limit-asks.xml
@@ -0,0 +1,19 @@
+
+
+ A request above the per-payment limit is never paid automatically: it arrives as an ordinary
+ Payment Request that the payer pays by swiping, and a manual payment does not count toward the
+ amount paid automatically. Requires the Active $5 a payment allowance from set-and-accept.xml
+ and a payer balance above the request.
+
+
+ Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active and at least one automatic payment made, per auto-pay-under-limit.xml
+ On the payee instance, open Subscriptions, the Payments tab (id "Tab-payments"), tap Request Payment (id "PaymentRequestRequestPayment") and send the payer a one-time Payment Request for $20 with the note "Artpack"
+ Switch to the payer instance
+ Verify the Payment Request sheet (id "PaymentRequestsSheet") opens for $20.00 from the payee, or the bell (id "PaymentRequestsBell") shows one pending request, and that nothing was sent automatically
+ Verify no "Payment Executed" notification was posted for this request
+ Pay it from the sheet (id "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay
+ Verify Bitcoin Sent shows about $20.00
+ Open Subscriptions, tap the Payments tab and verify the "Artpack" row is listed without "· Auto-paid" in its subtitle
+ Tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") still shows only the automatic payments, not the $20
+
+
diff --git a/journeys/allowances/auto-pay-under-limit.xml b/journeys/allowances/auto-pay-under-limit.xml
new file mode 100644
index 000000000..1a293c59b
--- /dev/null
+++ b/journeys/allowances/auto-pay-under-limit.xml
@@ -0,0 +1,22 @@
+
+
+ A request within both limits is paid without the payer seeing a sheet. The payer only gets a
+ "Payment Executed" notification, the payee receives the payment, and the payer's Payments tab and
+ allowance detail both account for it. Requires the Active allowance from set-and-accept.xml and
+ notification permission granted on the payer.
+
+
+ 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 payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (id "Tab-payments") and tap Request Payment (id "PaymentRequestRequestPayment")
+ Send the payer a one-time Payment Request for $2 with the note "Devlog"
+ Switch to the payer instance, with Bitkit in the foreground on the home screen
+ Verify that within about five seconds no Payment Request sheet opens and the bell (id "PaymentRequestsBell") shows no pending request
+ 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; with notifications not allowed it is an in-app toast
+ Switch to the payee instance and verify the payment arrives, either as the Received Instant Bitcoin sheet or as a $2.00 "Received from <payer>" row in Activity
+ Switch to the payer instance, open the drawer menu, tap Subscriptions and tap the Payments tab
+ Verify the "Devlog" row (id "PaymentRequestRow-<paymentRequestId>-one-time") is listed with a subtitle ending in "· Auto-paid"
+ Tap the Allowances tab, then tap the payee's row
+ Verify the detail sheet (id "AllowanceDetail") shows PAID AUTOMATICALLY "$2.00" (id "AllowancePaidSoFar") and the explanation "Requests within these limits are paid automatically. Anything above them asks you first." (id "AllowanceDetailStatus")
+ Open Activity from the home screen and verify the newest row reads "Sent to <payee>" for $2.00
+
+
diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml
new file mode 100644
index 000000000..f3003d79f
--- /dev/null
+++ b/journeys/allowances/end-stops-auto-pay.xml
@@ -0,0 +1,26 @@
+
+
+ Either side can end an allowance from its detail sheet, and from then on every request asks
+ again. The first half ends it on the payer; the second half sets a fresh allowance and ends it
+ on the payee. In both cases the payer's row keeps the amount that was paid automatically, and the
+ next request waits in the bell as an ordinary Payment Request.
+
+
+ Launch the E2E Bitkit app on both instances with an Active allowance that has paid at least one request automatically, per auto-pay-under-limit.xml
+ On the payer instance, open the drawer menu, tap Subscriptions, tap the Allowances tab and tap the payee's row
+ Verify the detail sheet (id "AllowanceDetail") ends with a swipe control reading "Swipe To End Allowance"
+ Swipe the control to the end
+ Verify the sheet dismisses and the row's status line (id "AllowanceRowStatus") reads "Ended · " followed by the amount paid automatically, such as "Ended · $2.00 paid automatically", with the row dimmed
+ Tap the row and verify the detail explanation (id "AllowanceDetailStatus") reads "This allowance has ended. Every request asks again." with no swipe control
+ On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterEnd"
+ 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
+ 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 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
new file mode 100644
index 000000000..1b32ebdd3
--- /dev/null
+++ b/journeys/allowances/monthly-cap-reached.xml
@@ -0,0 +1,22 @@
+
+
+ Requests keep paying themselves until the month's allowance is used up; the first request that
+ would exceed it is left to the payer with a "Limit Reached" notification and the ordinary Payment
+ Request sheet. The journey uses a $5 a payment, $10 a month allowance so two $4 requests fit and
+ the third does not. The second request can take a few extra seconds: after a payment the payee
+ publishes a new private payment list, and the payer waits for it rather than asking.
+
+
+ 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
+ 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>"
+ On the payee instance, wait for the payment to arrive, then send a second $4 request with the note "Cap2"
+ On the payer instance, verify it is paid without a sheet within about fifteen seconds, and a second "Payment Executed" notification is posted
+ On the payee instance, send a third $4 request with the note "Cap3"
+ On the payer instance, verify a "Limit Reached" notification reads "A $4.00 request from <payee> is above your allowance. Review it to pay." and the Payment Request sheet opens for $4.00
+ Dismiss the request (id "PaymentRequestDismiss-<paymentRequestId>")
+ Open Subscriptions, tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") reads "$8.00"
+
+
diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml
new file mode 100644
index 000000000..087725b8d
--- /dev/null
+++ b/journeys/allowances/pin-gates-allowance-changes.xml
@@ -0,0 +1,29 @@
+
+
+ When the payer requires a PIN for payments, setting an allowance and ending it ask for the same
+ PIN (or biometrics) as Send before anything changes, and a cancelled check changes nothing. The
+ payee's answers never ask. Automatic payments under an accepted allowance never ask: the checked
+ grant is their authorisation.
+
+ Precondition: the two-instance setup in README.md, with no allowance between them yet. On the
+ payer, enable a PIN you choose under Settings ▸ Security ▸ PIN Code and turn on "Also require for
+ payments"; no biometrics enrolled on the simulator, so the PIN pad shows directly. Never enter a
+ wrong PIN more than once.
+
+ iOS difference: the PIN pad is Send's PIN screen and opens as a sheet over the allowance sheet;
+ "press back" means its back button.
+
+
+ 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 Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no review sheet
+ 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 the review sheet (id "AllowanceReview") opens on its own, tap Accept (id "AllowanceAccept") and verify no PIN is asked
+ 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
+ Swipe it to the end again, enter the PIN, and verify the row reads "Ended · $2.00 paid automatically"
+
+
diff --git a/journeys/allowances/restart-never-pays-twice.xml b/journeys/allowances/restart-never-pays-twice.xml
new file mode 100644
index 000000000..e991ca742
--- /dev/null
+++ b/journeys/allowances/restart-never-pays-twice.xml
@@ -0,0 +1,21 @@
+
+
+ Killing the payer while an automatic payment is in flight must never lead to a second payment
+ after relaunch. The app records the request as taken before it hands the payment to the node, so
+ on relaunch it either sees the node finish the first attempt or hands the request back to the
+ payer as an ordinary Payment Request; it never starts a new automatic attempt. The kill has to
+ land within about two seconds of the request arriving, which the app log marks with "Handed an
+ allowance payment to the node".
+
+
+ Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active, per set-and-accept.xml
+ On the payer instance, start tailing the simulator log (`xcrun simctl spawn <udid> log stream --predicate 'subsystem == "to.bitkit"'`) for "Handed an allowance payment to the node" so the kill can follow it at once
+ On the payee instance, send the payer a one-time Payment Request for $2 with the note "Devlog3"
+ As soon as the log line appears on the payer, run `xcrun simctl terminate <udid> to.bitkit`
+ Relaunch the payer with `xcrun simctl launch <udid> to.bitkit` and wait for the node to come back up
+ Verify no "Payment Executed" notification is posted for a second attempt after the relaunch
+ Open the drawer menu, tap Subscriptions and tap the Payments tab; verify "Devlog3" is listed exactly once
+ On the payee instance, verify at most one $2.00 payment arrives for "Devlog3"
+ If the kill landed before the node took the payment: on the payer, verify "Devlog3" comes back as an ordinary Payment Request in the bell (id "PaymentRequestsBell") rather than being paid automatically, and dismiss it
+
+
diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml
new file mode 100644
index 000000000..e6b7e9c68
--- /dev/null
+++ b/journeys/allowances/set-and-accept.xml
@@ -0,0 +1,26 @@
+
+
+ The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer
+ open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an
+ answer"; after it, both sides show the allowance as Active. The other allowance journeys start
+ from the Active state this one ends in.
+
+
+ Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel
+ On the payer instance, open the drawer menu and tap Subscriptions
+ Tap the Allowances tab (id "Tab-allowances")
+ Verify the empty state (id "AllowancesEmpty") reads "Set up allowances" over the group illustration, with the footer button "Add Allowance"
+ Tap Add Allowance (id "AllowanceAdd")
+ Verify the Choose Contact sheet lists the payee and tap it (id "AllowanceContact-<displayName>")
+ Verify the Set Allowance sheet (id "SetAllowance") shows the payee's card, a PAYMENT LIMIT slider (id "AllowancePerPayment") and a MONTHLY ALLOWANCE slider (id "AllowanceMonthly")
+ Tap the $5 stop of the payment limit slider (id "AllowancePerPaymentStop-1") and the $50 stop of the monthly slider (id "AllowanceMonthlyStop-1")
+ Verify the summary (id "AllowanceSummary") reads "Requests up to $5 will be paid automatically, up to $50 a month, without asking you each time."
+ Tap Save Allowance (id "AllowanceSave")
+ Verify the sheet dismisses and the list shows one row for the payee (id "AllowanceRow-<entryId>") whose status line (id "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right
+ Switch to the payee instance and bring Bitkit to the foreground
+ Verify the Allowance review sheet (id "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (id "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (id "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (id "AllowanceMonthlyValue")
+ Tap Accept (id "AllowanceAccept")
+ Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit
+ Switch back to the payer instance and verify its row now reads "Active · $5 a payment"
+
+