diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
index 1d039e7f3..57a0fbd8f 100644
--- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
@@ -1517,6 +1517,7 @@
"subscriptions__allowances" = "المخصصات";
"subscriptions__allowances_empty_headline" = "أعدّ\nالمخصصات";
"subscriptions__allowances_paused_description" = "سجلات الميزانيات لديك أقدم من سجلات استخدمتها هذه المحفظة من قبل. ستطلب الطلبات موافقتك حتى تستأنف.";
+"subscriptions__allowances_paused_restore_description" = "تمت استعادة هذه المحفظة من نسخة احتياطية. ستطلب الطلبات موافقتك حتى تستأنف.";
"subscriptions__allowances_paused_resume" = "استئناف";
"subscriptions__allowances_paused_title" = "تم إيقاف الدفعات التلقائية مؤقتًا";
"subscriptions__allowances_empty_description" = "يمكنك تعيين حدود إنفاق لتلبية طلبات الدفع من جهات الاتصال الموثوقة تلقائيًا.";
diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
index d6efac452..bc7479bb9 100644
--- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
@@ -1701,6 +1701,7 @@
"subscriptions__allowances" = "Assignacions";
"subscriptions__allowances_empty_headline" = "Configura\nassignacions";
"subscriptions__allowances_paused_description" = "Els teus registres d'assignacions són més antics que altres que aquest moneder ja ha fet servir. Les sol·licituds demanaran la teva aprovació fins que reprenguis.";
+"subscriptions__allowances_paused_restore_description" = "Aquest moneder s'ha restaurat a partir d'una còpia de seguretat. Les sol·licituds demanaran la teva aprovació fins que reprenguis.";
"subscriptions__allowances_paused_resume" = "Reprèn";
"subscriptions__allowances_paused_title" = "Pagaments automàtics en pausa";
"subscriptions__allowances_empty_description" = "Pots establir límits de despesa per pagar automàticament les sol·licituds de pagament de contactes de confiança.";
diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
index 0b058c5d4..a1da56dc6 100644
--- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
@@ -1840,6 +1840,7 @@
"subscriptions__allowances" = "Povolení plateb";
"subscriptions__allowances_empty_headline" = "Nastavte\npovolení plateb";
"subscriptions__allowances_paused_description" = "Záznamy vašich rozpočtů jsou starší než ty, které tato peněženka už použila. Požadavky budou čekat na vaše schválení, dokud platby neobnovíte.";
+"subscriptions__allowances_paused_restore_description" = "Tato peněženka byla obnovena ze zálohy. Požadavky budou čekat na vaše schválení, dokud platby neobnovíte.";
"subscriptions__allowances_paused_resume" = "Obnovit";
"subscriptions__allowances_paused_title" = "Automatické platby pozastaveny";
"subscriptions__allowances_empty_description" = "Můžete nastavit limity útraty a automaticky tak vyřizovat žádosti o platbu od důvěryhodných kontaktů.";
diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
index 87ba20a48..900845c56 100644
--- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
@@ -1837,6 +1837,7 @@
"subscriptions__allowances" = "Kontingente";
"subscriptions__allowances_empty_headline" = "Kontingente\neinrichten";
"subscriptions__allowances_paused_description" = "Deine Budgetdaten sind älter als Daten, die diese Wallet bereits verwendet hat. Anfragen warten auf deine Bestätigung, bis du fortsetzt.";
+"subscriptions__allowances_paused_restore_description" = "Diese Wallet wurde aus einem Backup wiederhergestellt. Anfragen warten auf deine Bestätigung, bis du fortsetzt.";
"subscriptions__allowances_paused_resume" = "Fortsetzen";
"subscriptions__allowances_paused_title" = "Automatische Zahlungen pausiert";
"subscriptions__allowances_empty_description" = "Lege Ausgabenlimits fest, um Zahlungsanforderungen vertrauenswürdiger Kontakte automatisch zu bezahlen.";
diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
index 825c42abd..06224772a 100644
--- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
@@ -1572,6 +1572,7 @@
"subscriptions__allowances" = "Προεγκρίσεις";
"subscriptions__allowances_empty_headline" = "Ρύθμισε\nπροεγκρίσεις";
"subscriptions__allowances_paused_description" = "Τα αρχεία των προϋπολογισμών σου είναι παλαιότερα από αρχεία που έχει ήδη χρησιμοποιήσει αυτό το πορτοφόλι. Τα αιτήματα θα ζητούν την έγκρισή σου μέχρι να συνεχίσεις.";
+"subscriptions__allowances_paused_restore_description" = "Αυτό το πορτοφόλι επαναφέρθηκε από αντίγραφο ασφαλείας. Τα αιτήματα θα ζητούν την έγκρισή σου μέχρι να συνεχίσεις.";
"subscriptions__allowances_paused_resume" = "Συνέχεια";
"subscriptions__allowances_paused_title" = "Οι αυτόματες πληρωμές έχουν παυθεί";
"subscriptions__allowances_empty_description" = "Μπορείς να ορίσεις όρια δαπανών για να εξοφλούνται αυτόματα αιτήματα πληρωμής από αξιόπιστες επαφές.";
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index bace84d39..ee0603228 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -1714,6 +1714,7 @@
"subscriptions__allowances" = "Allowances";
"subscriptions__allowances_empty_headline" = "Set up\nallowances";
"subscriptions__allowances_paused_description" = "Your allowance records are older than ones this wallet has already used. Requests ask for your approval until you resume.";
+"subscriptions__allowances_paused_restore_description" = "This wallet was restored from a backup. Requests ask for your approval until you resume.";
"subscriptions__allowances_paused_resume" = "Resume";
"subscriptions__allowances_paused_title" = "Automatic payments paused";
"subscriptions__allowances_empty_description" = "You can set spending limits to automatically fulfill payment requests from trusted peers.";
diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
index 4efede781..458fc660b 100644
--- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
@@ -1843,6 +1843,7 @@
"subscriptions__allowances" = "Asignaciones";
"subscriptions__allowances_empty_headline" = "Configure\nasignaciones";
"subscriptions__allowances_paused_description" = "Tus registros de asignaciones son más antiguos que otros que esta billetera ya usó. Las solicitudes pedirán tu aprobación hasta que reanudes.";
+"subscriptions__allowances_paused_restore_description" = "Esta billetera se restauró desde una copia de seguridad. Las solicitudes pedirán tu aprobación hasta que reanudes.";
"subscriptions__allowances_paused_resume" = "Reanudar";
"subscriptions__allowances_paused_title" = "Pagos automáticos en pausa";
"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza.";
diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
index f6e401390..a46cb1e8f 100644
--- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
@@ -1721,6 +1721,7 @@
"subscriptions__allowances" = "Asignaciones";
"subscriptions__allowances_empty_headline" = "Configure\nasignaciones";
"subscriptions__allowances_paused_description" = "Tus registros de asignaciones son más antiguos que otros que este monedero ya ha usado. Las solicitudes pedirán tu aprobación hasta que reanudes.";
+"subscriptions__allowances_paused_restore_description" = "Este monedero se ha restaurado desde una copia de seguridad. Las solicitudes pedirán tu aprobación hasta que reanudes.";
"subscriptions__allowances_paused_resume" = "Reanudar";
"subscriptions__allowances_paused_title" = "Pagos automáticos en pausa";
"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza.";
diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
index 4e7711046..3899a022b 100644
--- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
@@ -1851,6 +1851,7 @@
"subscriptions__allowances" = "Allocations";
"subscriptions__allowances_empty_headline" = "Configurez\ndes allocations";
"subscriptions__allowances_paused_description" = "Vos données d'allocations sont plus anciennes que celles que ce portefeuille a déjà utilisées. Les demandes attendront votre accord jusqu'à la reprise.";
+"subscriptions__allowances_paused_restore_description" = "Ce portefeuille a été restauré à partir d'une sauvegarde. Les demandes attendront votre accord jusqu'à la reprise.";
"subscriptions__allowances_paused_resume" = "Reprendre";
"subscriptions__allowances_paused_title" = "Paiements automatiques en pause";
"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.";
diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
index 818e6a0f8..f239839b7 100644
--- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
@@ -1810,6 +1810,7 @@
"subscriptions__allowances" = "Plafond";
"subscriptions__allowances_empty_headline" = "Imposta\ni plafond";
"subscriptions__allowances_paused_description" = "I dati dei tuoi budget sono più vecchi di quelli che questo wallet ha già usato. Le richieste chiederanno la tua approvazione finché non riprendi.";
+"subscriptions__allowances_paused_restore_description" = "Questo wallet è stato ripristinato da un backup. Le richieste chiederanno la tua approvazione finché non riprendi.";
"subscriptions__allowances_paused_resume" = "Riprendi";
"subscriptions__allowances_paused_title" = "Pagamenti automatici in pausa";
"subscriptions__allowances_empty_description" = "Puoi impostare limiti di spesa per pagare automaticamente le richieste di pagamento dei contatti fidati.";
diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
index 3aad80411..045c523d4 100644
--- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
@@ -1845,6 +1845,7 @@
"subscriptions__allowances" = "Toelagen";
"subscriptions__allowances_empty_headline" = "Stel\ntoelagen in";
"subscriptions__allowances_paused_description" = "Je toelagengegevens zijn ouder dan gegevens die deze wallet al heeft gebruikt. Verzoeken vragen om je goedkeuring tot je hervat.";
+"subscriptions__allowances_paused_restore_description" = "Deze wallet is hersteld vanuit een backup. Verzoeken vragen om je goedkeuring tot je hervat.";
"subscriptions__allowances_paused_resume" = "Hervatten";
"subscriptions__allowances_paused_title" = "Automatische betalingen gepauzeerd";
"subscriptions__allowances_empty_description" = "U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen.";
diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
index c02d5358b..4d2186514 100644
--- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
@@ -1850,6 +1850,7 @@
"subscriptions__allowances" = "Przydziały";
"subscriptions__allowances_empty_headline" = "Ustaw\nprzydziały";
"subscriptions__allowances_paused_description" = "Dane Twoich budżetów są starsze niż te, których ten portfel już użył. Prośby będą czekać na Twoją zgodę, dopóki nie wznowisz.";
+"subscriptions__allowances_paused_restore_description" = "Ten portfel został przywrócony z kopii zapasowej. Prośby będą czekać na Twoją zgodę, dopóki nie wznowisz.";
"subscriptions__allowances_paused_resume" = "Wznów";
"subscriptions__allowances_paused_title" = "Płatności automatyczne wstrzymane";
"subscriptions__allowances_empty_description" = "Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów.";
diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
index fb2049f2a..623aa0578 100644
--- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
@@ -1851,6 +1851,7 @@
"subscriptions__allowances" = "Pré-aprovações";
"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações";
"subscriptions__allowances_paused_description" = "Os registros das suas mesadas são mais antigos do que outros que esta carteira já usou. As solicitações vão pedir sua aprovação até você retomar.";
+"subscriptions__allowances_paused_restore_description" = "Esta carteira foi restaurada a partir de um backup. As solicitações vão pedir sua aprovação até você retomar.";
"subscriptions__allowances_paused_resume" = "Retomar";
"subscriptions__allowances_paused_title" = "Pagamentos automáticos pausados";
"subscriptions__allowances_empty_description" = "Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos confiáveis.";
diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
index e9a82446c..53e9c16bc 100644
--- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
@@ -1497,6 +1497,7 @@
"subscriptions__allowances" = "Pré-aprovações";
"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações";
"subscriptions__allowances_paused_description" = "Os registos das suas mesadas são mais antigos do que outros que esta carteira já usou. Os pedidos vão pedir a sua aprovação até retomar.";
+"subscriptions__allowances_paused_restore_description" = "Esta carteira foi restaurada a partir de um backup. Os pedidos vão pedir a sua aprovação até retomar.";
"subscriptions__allowances_paused_resume" = "Retomar";
"subscriptions__allowances_paused_title" = "Pagamentos automáticos em pausa";
"subscriptions__allowances_empty_description" = "Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança.";
diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
index e9499eaae..d71a5b9f6 100644
--- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
@@ -1831,6 +1831,7 @@
"subscriptions__allowances" = "Лимиты автооплаты";
"subscriptions__allowances_empty_headline" = "Настройте\nлимиты автооплаты";
"subscriptions__allowances_paused_description" = "Данные ваших бюджетов старше тех, что этот кошелёк уже использовал. Запросы будут ждать вашего подтверждения, пока вы не возобновите платежи.";
+"subscriptions__allowances_paused_restore_description" = "Этот кошелёк был восстановлен из резервной копии. Запросы будут ждать вашего подтверждения, пока вы не возобновите платежи.";
"subscriptions__allowances_paused_resume" = "Возобновить";
"subscriptions__allowances_paused_title" = "Автоматические платежи приостановлены";
"subscriptions__allowances_empty_description" = "Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов.";
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 5eeafe254..7ace6170a 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -691,14 +691,10 @@ class BackupService {
try await performRestore(category: .wallet, retainedData: retainedWalletBackup) { dataBytes in
try walletRestoreGate.retain(dataBytes)
let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes)
- if let paymentState = payload.paykitPaymentState {
+ try await Self.restorePaykitPaymentState(from: payload) { paymentState, hasLedger in
// VssBackupClient's existing custom backup namespace is explicitly wallet index 0.
let backupWallet = try await PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0)
- try await PaykitPaymentProofService.shared.restoreBackup(
- paymentState,
- wallet: backupWallet,
- hasLedger: payload.paykitSdkBackupState != nil
- )
+ try await PaykitPaymentProofService.shared.restoreBackup(paymentState, wallet: backupWallet, hasLedger: hasLedger)
}
try TransferStorage.shared.upsertList(payload.transfers)
await PrivatePaykitAddressReservationStore.shared.restoreBackup(
@@ -1422,6 +1418,19 @@ class BackupService {
markRestoreComplete(category: category)
}
+ /// A backup can predate payments made since, so restoring a wallet payload pauses automatic payments before any Paykit
+ /// state comes back, also for a payload without payment state, until the user resumes them. The pause is kept per
+ /// device, so it applies even when no allowance this wallet pays through existed at the restore.
+ static func restorePaykitPaymentState(
+ from payload: WalletBackupV1,
+ allowanceStore: any PaykitAllowanceStoring = PaykitAllowanceKeychainStore(),
+ restore: (_ paymentState: PaykitPaymentStateBackup, _ hasLedger: Bool) async throws -> Void
+ ) async throws {
+ try allowanceStore.setPausedAfterRestore(true)
+ guard let paymentState = payload.paykitPaymentState else { return }
+ try await restore(paymentState, payload.paykitSdkBackupState != nil)
+ }
+
func markRestoreComplete(category: BackupCategory) {
let currentTime = UInt64(Date().timeIntervalSince1970)
updateBackupStatus(category: category) { status in
diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift
index 3455e4e7c..0cf256a0a 100644
--- a/Bitkit/Services/PaykitAllowance.swift
+++ b/Bitkit/Services/PaykitAllowance.swift
@@ -40,6 +40,9 @@ struct PaykitAllowance: Identifiable, Hashable {
let lastEventAt: Date?
/// The proposal's stream item as displayed; acceptance names it so a re-sent proposal asks again.
let proposalStreamItemId: UInt64?
+ /// Terms with a limit beyond the per-payment cap and the one monthly amount Bitkit checks itself, such as a lifetime
+ /// amount, a payment count or another period. Bitkit never proposes these, and they never pay automatically.
+ let hasUncheckedLimits: Bool
var counterparty: String {
id.counterparty
@@ -65,7 +68,8 @@ struct PaykitAllowance: Identifiable, Hashable {
terms.asset() == PaykitIssuerInterop.bitcoinAsset
else { return nil }
- let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) }
+ let periodLimits = terms.periodLimits()
+ let monthly = 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)
@@ -85,6 +89,8 @@ struct PaykitAllowance: Identifiable, Hashable {
allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers()
lastEventAt = record.lastEventAt.flatMap(PaykitAllowanceTime.parse)
proposalStreamItemId = record.proposalStreamItemId
+ hasUncheckedLimits = terms.lifetimeAmountLimit() != nil || periodLimits.count > 1 ||
+ periodLimits.contains { !Self.isMonthly($0.period()) || $0.paymentCountLimit() != nil }
}
init(
@@ -99,7 +105,8 @@ struct PaykitAllowance: Identifiable, Hashable {
expiresAt: Date? = nil,
allowedPaymentEndpointIdentifiers: [String]? = nil,
lastEventAt: Date? = nil,
- proposalStreamItemId: UInt64? = nil
+ proposalStreamItemId: UInt64? = nil,
+ hasUncheckedLimits: Bool = false
) {
self.id = id
self.role = role
@@ -113,6 +120,7 @@ struct PaykitAllowance: Identifiable, Hashable {
self.allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers
self.lastEventAt = lastEventAt
self.proposalStreamItemId = proposalStreamItemId
+ self.hasUncheckedLimits = hasUncheckedLimits
}
func status(at now: Date) -> Status {
@@ -319,6 +327,29 @@ enum PaykitAllowanceCapacity {
return used + amountSats <= monthlyLimitSats
}
+ enum Fit: Equatable {
+ /// The allowances with only the limits this wallet checks that fit the request, in the order given; the request can
+ /// pay automatically through one of them.
+ case automatic([PaykitAllowance])
+ /// Only allowances with limits this wallet does not check fit, so the request asks without Limit Reached.
+ case uncheckedOnly
+ /// No allowance fits the request, so it asks and Limit Reached is raised.
+ case none
+ }
+
+ /// Which of a request's matching allowances can pay it automatically. Every allowance is measured against the
+ /// per-payment cap and monthly amount this wallet checks; only one without other limits can pay automatically.
+ static func fit(amountSats: UInt64, allowances: [PaykitAllowance], attempts: [Attempt], now: Date, localAttempts: [Attempt] = []) -> Fit {
+ let fitting = allowances.filter {
+ fits(amountSats: amountSats, allowance: $0, attempts: attempts, now: now, localAttempts: localAttempts)
+ }
+ let automatic = fitting.filter { !$0.hasUncheckedLimits }
+ if !automatic.isEmpty {
+ return .automatic(automatic)
+ }
+ return fitting.isEmpty ? .none : .uncheckedOnly
+ }
+
static func attempts(from spends: [PaykitAllowanceLocalState.AutomaticSpend]) -> [Attempt] {
spends.map {
Attempt(
diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift
index 39087cb41..9dc342f2f 100644
--- a/Bitkit/Services/PaykitAllowanceExecutor.swift
+++ b/Bitkit/Services/PaykitAllowanceExecutor.swift
@@ -144,6 +144,9 @@ extension PaykitAllowanceLocalState {
protocol PaykitAllowanceStoring: Sendable {
func load(identity: String) throws -> PaykitAllowanceLocalState
func save(_ state: PaykitAllowanceLocalState, identity: String) throws
+ /// Whether automatic payments are paused since the wallet was restored from a backup, until the user resumes them.
+ func isPausedAfterRestore() throws -> Bool
+ func setPausedAfterRestore(_ isPaused: Bool) throws
}
struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring {
@@ -171,6 +174,19 @@ struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring {
}
}
+ /// Kept on this device only and never in a backup, so every restore pauses automatic payments again.
+ func isPausedAfterRestore() throws -> Bool {
+ try Keychain.load(key: .paykitAllowancesPausedAfterRestore) != nil
+ }
+
+ func setPausedAfterRestore(_ isPaused: Bool) throws {
+ if isPaused {
+ try Keychain.upsert(key: .paykitAllowancesPausedAfterRestore, data: Data([1]))
+ } else {
+ try Keychain.delete(key: .paykitAllowancesPausedAfterRestore)
+ }
+ }
+
/// Nil when the state can't be read, so the rest of the wallet backup still goes out.
func backupSnapshot() -> PaykitAllowanceStateBackups? {
do {
@@ -525,13 +541,14 @@ actor PaykitAllowanceExecutor {
/// True while the ledger read is older than one this wallet has read, which keeps automatic payments paused.
func isLedgerBehind(identity: String) async -> Bool {
+ await (try? checkedIsLedgerBehind(identity: identity)) ?? false
+ }
+
+ /// Like `isLedgerBehind(identity:)`, but throws when the ledger can't be read.
+ func checkedIsLedgerBehind(identity: String) async throws -> Bool {
guard let floor = localState(identity: identity).ledgerRevisionFloor else { return false }
- do {
- guard let ledger = try await sdk.allowanceAccountingState() else { return true }
- return ledger.revision < floor
- } catch {
- return false
- }
+ guard let ledger = try await sdk.allowanceAccountingState() else { return true }
+ return ledger.revision < floor
}
/// Takes the ledger read now as current, so automatic payments resume from it. This wallet's own spends, paid requests and
@@ -541,6 +558,17 @@ actor PaykitAllowanceExecutor {
try checkedUpdateLocalState(identity: identity) { $0.ledgerRevisionFloor = ledger?.revision }
}
+ /// True from a wallet restore until the user resumes automatic payments, also when no allowance this wallet pays through
+ /// existed at the restore: the pause is kept per device, not per allowance. A pause that can't be read counts as paused.
+ func isPausedAfterRestore() -> Bool {
+ (try? store.isPausedAfterRestore()) ?? true
+ }
+
+ /// Ends the pause a wallet restore started; the restored spends, paid requests and ended allowances still apply.
+ func resumeAfterRestore() throws {
+ try store.setPausedAfterRestore(false)
+ }
+
func isHandling(_ requestId: PaykitPaymentRequest.ID) -> Bool {
inFlightRequestIds.contains(requestId)
}
@@ -669,12 +697,16 @@ actor PaykitAllowanceExecutor {
return false
}
- /// Whether a covered request is over every limit of the contact's accepted allowances, decided without the node.
+ /// Whether a covered request goes back to the manual flow without waiting for the node, decided like an automatic payment:
+ /// it waits only while an allowance with just the limits this wallet checks fits it. Limit Reached is raised only when no
+ /// matching allowance fits at all. Allowances that all ended here hand the request back without it.
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)
- }
+ let covering = coveringAllowances(for: request, allowances: allowances)
guard !covering.isEmpty else { return false }
+ // Paused automatic payments keep the request manual, without waiting for the node.
+ guard !isPausedAfterRestore() else { return true }
+ let matching = unendedAllowances(covering, identity: identity)
+ guard !matching.isEmpty else { return true }
var attempts: [PaykitAllowanceCapacity.Attempt] = []
do {
let ledger = try await sdk.allowanceAccountingState()
@@ -685,17 +717,33 @@ actor PaykitAllowanceExecutor {
Logger.warn("Failed to read allowance accounting for the limit check: \(error)", context: "PaykitAllowance")
}
let localAttempts = PaykitAllowanceCapacity.attempts(from: localState(identity: identity).automaticSpends)
- guard !covering.contains(where: {
- PaykitAllowanceCapacity.fits(
- amountSats: request.allowanceAmountSats,
- allowance: $0,
- attempts: attempts,
- now: now(),
- localAttempts: localAttempts
- )
- }) else { return false }
- notifyLimitReached(request, identity: identity)
- return true
+ switch PaykitAllowanceCapacity.fit(
+ amountSats: request.allowanceAmountSats,
+ allowances: matching,
+ attempts: attempts,
+ now: now(),
+ localAttempts: localAttempts
+ ) {
+ case .automatic:
+ return false
+ case .uncheckedOnly:
+ return true
+ case .none:
+ notifyLimitReached(request, identity: identity)
+ return true
+ }
+ }
+
+ private func coveringAllowances(for request: PaykitPaymentRequest, allowances: [PaykitAllowance]) -> [PaykitAllowance] {
+ allowances.filter {
+ $0.isAllower && $0.status(at: now()) == .active && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty)
+ }
+ }
+
+ /// An End saved here stops an allowance even while the SDK still lists it as accepted.
+ private func unendedAllowances(_ allowances: [PaykitAllowance], identity: String) -> [PaykitAllowance] {
+ let ended = localState(identity: identity).endedAllowanceIds
+ return allowances.filter { !ended.contains($0.scopedId) }
}
private struct NodeHistoryUnavailable: Error {}
@@ -886,6 +934,10 @@ actor PaykitAllowanceExecutor {
PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty)
})
else { return .notCovered }
+ guard !isPausedAfterRestore() else {
+ Logger.info("Kept an incoming request manual: automatic payments are paused since the wallet was restored", context: "PaykitAllowance")
+ return .manual
+ }
inFlightRequestIds.insert(request.id)
defer { inFlightRequestIds.remove(request.id) }
@@ -922,28 +974,15 @@ actor PaykitAllowanceExecutor {
let selectionTime = trustedTime(identity: identity)
let candidates = try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: selectionTime)
let local = localState(identity: identity)
- let unblocked = candidates.filter { $0.blocked == nil }
- let unended = unblocked.filter {
- !local.endedAllowanceIds.contains(PaykitAllowance.scopedId(counterparty: request.counterparty, allowanceId: $0.allowanceId))
- }
- if unended.isEmpty, !unblocked.isEmpty {
- Logger.info("Kept an incoming request manual: its allowance has ended", context: "PaykitAllowance")
- return .manual
- }
- guard let candidate = unended.first,
- 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)
- }
+ let covering = coveringAllowances(for: request, allowances: allowances)
+ let matching = unendedAllowances(covering, identity: identity)
+ guard !matching.isEmpty else {
+ Logger.info(
+ covering.isEmpty
+ ? "Kept an incoming request manual: no active allowance covers it"
+ : "Kept an incoming request manual: its allowance has ended",
+ context: "PaykitAllowance"
+ )
return .manual
}
@@ -952,15 +991,33 @@ actor PaykitAllowanceExecutor {
Logger.info("Kept an incoming request manual: the ledger is behind this wallet", context: "PaykitAllowance")
return .manual
}
- guard PaykitAllowanceCapacity.fits(
+ // Bitkit checks the per-payment cap and one monthly amount itself, so only an allowance without other limits pays,
+ // through the first candidate the SDK leaves unblocked.
+ let fit = PaykitAllowanceCapacity.fit(
amountSats: request.allowanceAmountSats,
- allowance: allowance,
+ allowances: matching,
attempts: ledger.map { PaykitAllowanceCapacity.attempts(from: $0.history) } ?? [],
now: now(),
localAttempts: PaykitAllowanceCapacity.attempts(from: local.automaticSpends)
- ) else {
- Logger.info("Allowance monthly limit reached; the request stays on the manual flow", context: "PaykitAllowance")
- notifyLimitReached(request, identity: identity)
+ )
+ let payable = if case let .automatic(allowances) = fit {
+ allowances
+ } else {
+ [PaykitAllowance]()
+ }
+ guard let candidate = candidates.first(where: { candidate in
+ candidate.blocked == nil && payable.contains { $0.allowanceId == candidate.allowanceId }
+ }) else {
+ let reasons = candidates.compactMap { $0.blocked.map { String(describing: $0) } }
+ if fit == .none {
+ Logger.info("No allowance fits an incoming request; it stays on the manual flow: \(reasons)", context: "PaykitAllowance")
+ notifyLimitReached(request, identity: identity)
+ } else {
+ Logger.info(
+ "No allowance can pay an incoming request automatically; it stays on the manual flow: \(reasons)",
+ context: "PaykitAllowance"
+ )
+ }
return .manual
}
diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift
index f983011ee..3e2ca03bb 100644
--- a/Bitkit/Services/PaykitAllowanceManager.swift
+++ b/Bitkit/Services/PaykitAllowanceManager.swift
@@ -60,8 +60,8 @@ final class PaykitAllowanceManager {
private(set) var allowances: [PaykitAllowance] = []
private(set) var localState = PaykitAllowanceLocalState()
private(set) var isWorking = false
- /// True while the allowance ledger is behind one this wallet has read, until `resumeAutomaticPayments()`.
- private(set) var isAutomaticPaymentPaused = false
+ /// Why automatic payments are paused until `resumeAutomaticPayments()`, or nil while they run.
+ private(set) var automaticPaymentPause: AutomaticPaymentPause?
private(set) var autoPaidRequestIds: Set = []
/// Keyed by `PaykitAllowance.scopedId`, since another contact can reuse an allowance id.
private(set) var autoPaidSatsByScopedId: [String: UInt64] = [:]
@@ -86,6 +86,17 @@ final class PaykitAllowanceManager {
@ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:]
+ enum AutomaticPaymentPause: Equatable {
+ /// The allowance ledger is behind one this wallet has read.
+ case ledgerBehind
+ /// The wallet was restored from a backup and this wallet pays through an allowance.
+ case walletRestored
+ }
+
+ var isAutomaticPaymentPaused: Bool {
+ automaticPaymentPause != nil
+ }
+
init(
sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared,
executor: PaykitAllowanceExecutor = .shared,
@@ -172,7 +183,7 @@ final class PaykitAllowanceManager {
localState = PaykitAllowanceLocalState()
autoPaidRequestIds = []
autoPaidSatsByScopedId = [:]
- isAutomaticPaymentPaused = false
+ automaticPaymentPause = nil
manualRequestIds = [:]
announcedOfferIds = []
}
@@ -195,10 +206,17 @@ final class PaykitAllowanceManager {
Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance")
}
let state = await executor.localState(identity: identity)
- let isPaused = await executor.isLedgerBehind(identity: identity)
+ let isLedgerBehind = await executor.isLedgerBehind(identity: identity)
+ let isPausedAfterRestore = await executor.isPausedAfterRestore()
guard activation == refreshActivation else { return }
localState = state
- isAutomaticPaymentPaused = isPaused
+ automaticPaymentPause = if isLedgerBehind {
+ .ledgerBehind
+ } else if isPausedAfterRestore, allowances.contains(where: { $0.isAllower && [.proposed, .accepted].contains($0.lifecycleState) }) {
+ .walletRestored
+ } else {
+ nil
+ }
guard let paid = await executor.succeededAutomaticPayments(identity: identity), activation == refreshActivation else { return }
autoPaidRequestIds = Set(paid.map(\.requestId))
autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, payment in
@@ -283,12 +301,19 @@ final class PaykitAllowanceManager {
}
}
- /// Resumes automatic payments from the ledger read now; see `PaykitAllowanceExecutor.acceptCurrentLedger(identity:)`.
+ /// Resumes automatic payments from the ledger read now, also after a wallet restore; see
+ /// `PaykitAllowanceExecutor.acceptCurrentLedger(identity:)`. A Resume of the restore pause first reads the ledger again:
+ /// one that has fallen behind since is not accepted, and the refresh shows its own notice instead.
func resumeAutomaticPayments() async throws {
isWorking = true
defer { isWorking = false }
guard let identity else { throw PaykitAllowanceError.unavailable }
+ if automaticPaymentPause == .walletRestored, try await executor.checkedIsLedgerBehind(identity: identity) {
+ await refresh()
+ return
+ }
try await executor.acceptCurrentLedger(identity: identity)
+ try await executor.resumeAfterRestore()
await executor.recover(identity: identity)
await refresh()
}
diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift
index d1b717a35..20edc9003 100644
--- a/Bitkit/Utilities/Keychain.swift
+++ b/Bitkit/Utilities/Keychain.swift
@@ -18,6 +18,7 @@ enum KeychainEntryType {
case paykitPendingBackupRestore
case usdtPendingBackupRestore
case paykitAllowanceState
+ case paykitAllowancesPausedAfterRestore
case pubkySecretKey
var storageKey: String {
@@ -38,6 +39,7 @@ enum KeychainEntryType {
case .paykitPendingBackupRestore: "paykit_pending_backup_restore"
case .usdtPendingBackupRestore: "usdt_pending_backup_restore"
case .paykitAllowanceState: "paykit_allowance_state"
+ case .paykitAllowancesPausedAfterRestore: "paykit_allowances_paused_after_restore"
case .pubkySecretKey: "pubky_secret_key"
}
}
diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift
index 10e67444d..18ad56876 100644
--- a/Bitkit/Views/Subscriptions/AllowancesView.swift
+++ b/Bitkit/Views/Subscriptions/AllowancesView.swift
@@ -49,7 +49,7 @@ struct AllowancesTab: View {
private var pausedNotice: some View {
VStack(alignment: .leading, spacing: 0) {
BodyMSBText(t("subscriptions__allowances_paused_title"))
- BodySText(t("subscriptions__allowances_paused_description"), textColor: .white64)
+ BodySText(pausedDescription, textColor: .white64)
.padding(.top, 4)
CustomButton(
title: t("subscriptions__allowances_paused_resume"),
@@ -76,6 +76,15 @@ struct AllowancesTab: View {
.accessibilityIdentifier("AllowancesPaused")
}
+ private var pausedDescription: String {
+ switch allowances.automaticPaymentPause {
+ case .walletRestored:
+ t("subscriptions__allowances_paused_restore_description")
+ case .ledgerBehind, nil:
+ t("subscriptions__allowances_paused_description")
+ }
+ }
+
private var emptyState: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer()
diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift
index 47e0e65fe..8ba97d657 100644
--- a/BitkitTests/PaykitAllowanceExecutorTests.swift
+++ b/BitkitTests/PaykitAllowanceExecutorTests.swift
@@ -452,6 +452,382 @@ final class PaykitAllowanceExecutorTests: XCTestCase {
XCTAssertTrue(events.events.isEmpty, "A ledger behind the wallet raises no Limit Reached")
}
+ // MARK: Wallet restore
+
+ func testAWalletRestoreKeepsCoveredRequestsManualAcrossARelaunchUntilResumed() async throws {
+ let harness = AllowanceHarness()
+ try harness.store.setPausedAfterRestore(true)
+
+ let paused = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+ let uncovered = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [], identity: Fixtures.identityKey)
+ let relaunched = PaykitAllowanceExecutor(
+ sdk: harness.sdk,
+ store: harness.store,
+ payer: harness.payer,
+ lightningLookup: harness.lookup,
+ now: { PaykitAllowanceFixtures.now }
+ )
+ let pausedAfterRelaunch = try await relaunched.autoPay(
+ Fixtures.paymentRequest(),
+ allowances: [Fixtures.allowance()],
+ identity: Fixtures.identityKey
+ )
+ let admittedWhilePaused = harness.log.entries.contains("evaluateAllowanceCandidates")
+ try await relaunched.resumeAfterRestore()
+ let resumed = try await relaunched.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(paused, .manual)
+ XCTAssertEqual(uncovered, .notCovered)
+ XCTAssertEqual(pausedAfterRelaunch, .manual)
+ XCTAssertFalse(admittedWhilePaused)
+ XCTAssertEqual(resumed, .started)
+ let isPaused = await relaunched.isPausedAfterRestore()
+ XCTAssertFalse(isPaused)
+ }
+
+ func testLimitCheckHandsBackARequestWhileAutomaticPaymentsArePausedAfterARestore() async throws {
+ let harness = AllowanceHarness()
+ try harness.store.setPausedAfterRestore(true)
+ let events = AllowanceEventRecorder()
+
+ let isManual = try await harness.executor.exceedsLimits(
+ Fixtures.paymentRequest(),
+ allowances: [Fixtures.allowance()],
+ identity: Fixtures.identityKey
+ )
+
+ XCTAssertTrue(isManual)
+ XCTAssertTrue(events.events.isEmpty, "A paused wallet raises no Limit Reached")
+ }
+
+ @MainActor
+ func testManagerShowsTheRestorePauseToAPayerUntilResumed() async throws {
+ let harness = AllowanceHarness()
+ try harness.store.setPausedAfterRestore(true)
+ try await harness.sdk.setRecords([
+ Fixtures.record(localRole: .allowee, terms: Fixtures.standardTerms(), proposedByMe: false),
+ Fixtures.record(allowanceId: Fixtures.otherAllowanceId, state: .ended, terms: Fixtures.standardTerms()),
+ ])
+ let manager = PaykitAllowanceManager(
+ sdk: harness.sdk,
+ executor: harness.executor,
+ now: { PaykitAllowanceFixtures.now },
+ canPayNow: { true }
+ )
+ await manager.activate(identity: Fixtures.identityKey)
+ let pauseWithoutPayerAllowance = manager.automaticPaymentPause
+
+ try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())])
+ await manager.refresh()
+ let pauseAsPayer = manager.automaticPaymentPause
+ try await manager.resumeAutomaticPayments()
+
+ XCTAssertNil(pauseWithoutPayerAllowance, "Only a wallet with a proposed or accepted allowance it pays through has payments to resume")
+ XCTAssertEqual(pauseAsPayer, .walletRestored)
+ XCTAssertNil(manager.automaticPaymentPause)
+ let isPaused = await harness.executor.isPausedAfterRestore()
+ XCTAssertFalse(isPaused)
+ }
+
+ @MainActor
+ func testResumingTheRestorePauseNeverAcceptsALedgerThatFellBehind() async throws {
+ let harness = AllowanceHarness()
+ try harness.store.setPausedAfterRestore(true)
+ 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 pauseShown = manager.automaticPaymentPause
+ harness.store.seed(PaykitAllowanceLocalState(ledgerRevisionFloor: 5), identity: Fixtures.identityKey)
+ await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4))
+
+ try await manager.resumeAutomaticPayments()
+
+ XCTAssertEqual(pauseShown, .walletRestored)
+ XCTAssertEqual(manager.automaticPaymentPause, .ledgerBehind, "The ledger that fell behind gets its own notice")
+ let floor = await harness.executor.localState(identity: Fixtures.identityKey).ledgerRevisionFloor
+ XCTAssertEqual(floor, 5)
+ let isPaused = await harness.executor.isPausedAfterRestore()
+ XCTAssertTrue(isPaused)
+ }
+
+ @MainActor
+ func testResumingTheRestorePauseKeepsItWhenTheLedgerCannotBeRead() async throws {
+ let harness = AllowanceHarness()
+ try harness.store.setPausedAfterRestore(true)
+ harness.store.seed(PaykitAllowanceLocalState(ledgerRevisionFloor: 1), identity: Fixtures.identityKey)
+ 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)
+ await harness.sdk.setAccountingError(AllowanceMockError.unsupported)
+
+ do {
+ try await manager.resumeAutomaticPayments()
+ XCTFail("A ledger that cannot be read must fail the Resume")
+ } catch {}
+
+ XCTAssertEqual(manager.automaticPaymentPause, .walletRestored)
+ let isPaused = await harness.executor.isPausedAfterRestore()
+ XCTAssertTrue(isPaused)
+ }
+
+ @MainActor
+ func testManagerExplainsALedgerBehindThisWalletWhenARestoreAlsoPausedPayments() async throws {
+ let harness = AllowanceHarness()
+ try harness.store.setPausedAfterRestore(true)
+ harness.store.seed(PaykitAllowanceLocalState(ledgerRevisionFloor: 5), identity: Fixtures.identityKey)
+ await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4))
+ 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)
+
+ XCTAssertEqual(manager.automaticPaymentPause, .ledgerBehind)
+ }
+
+ func testRestoringAWalletPayloadPausesAutomaticPaymentsBeforeItsPaykitState() async throws {
+ let store = AllowanceMemoryStore()
+ var restoredWhilePaused: [Bool] = []
+ let restoreState: (PaykitPaymentStateBackup, Bool) async throws -> Void = { _, hasLedger in
+ XCTAssertTrue(hasLedger)
+ let isPaused = try store.isPausedAfterRestore()
+ restoredWhilePaused.append(isPaused)
+ }
+
+ try await BackupService.restorePaykitPaymentState(from: Self.walletPayload(), allowanceStore: store, restore: restoreState)
+ let pausedWithoutPaymentState = try store.isPausedAfterRestore()
+ try store.setPausedAfterRestore(false)
+ try await BackupService.restorePaykitPaymentState(
+ from: Self.walletPayload(paymentState: PaykitPaymentStateBackup(subscriptions: [:], pendingProofs: []), sdkState: "state"),
+ allowanceStore: store,
+ restore: restoreState
+ )
+
+ XCTAssertTrue(pausedWithoutPaymentState, "A backup without payment state still pauses automatic payments")
+ XCTAssertEqual(restoredWhilePaused, [true], "The pause is saved before the payment state is restored")
+ XCTAssertTrue(try store.isPausedAfterRestore())
+ }
+
+ func testAPauseThatCannotBeSavedFailsTheWalletRestore() async throws {
+ let store = AllowanceFailingPauseStore()
+ var restored = false
+
+ do {
+ try await BackupService.restorePaykitPaymentState(
+ from: Self.walletPayload(paymentState: PaykitPaymentStateBackup(subscriptions: [:], pendingProofs: [])),
+ allowanceStore: store
+ ) { _, _ in restored = true }
+ XCTFail("A restore that cannot pause automatic payments must fail")
+ } catch {}
+
+ XCTAssertFalse(restored)
+ }
+
+ func testLimitCheckHandsBackARequestCoveredOnlyByLimitsThisWalletDoesNotCheck() async throws {
+ let harness = AllowanceHarness()
+ let events = AllowanceEventRecorder()
+ let allowance = try XCTUnwrap(PaykitAllowance(record: Fixtures.record(terms: Fixtures.customTerms(lifetimeAmountLimit: "0.001"))))
+
+ let isManual = try await harness.executor.exceedsLimits(Fixtures.paymentRequest(), allowances: [allowance], identity: Fixtures.identityKey)
+
+ XCTAssertTrue(isManual)
+ XCTAssertTrue(events.events.isEmpty, "An allowance with limits this wallet does not check raises no Limit Reached")
+ }
+
+ func testLimitCheckHandsBackARequestOnlyAnAllowanceWithUncheckedLimitsFitsWithoutLimitReached() async throws {
+ let harness = AllowanceHarness()
+ let events = AllowanceEventRecorder()
+
+ let isManual = try await harness.executor.exceedsLimits(
+ Fixtures.paymentRequest(amount: "0.0002"),
+ allowances: Self.checkedAndUncheckedAllowances(),
+ identity: Fixtures.identityKey
+ )
+
+ XCTAssertTrue(isManual)
+ XCTAssertTrue(events.events.isEmpty, "A request an allowance still fits raises no Limit Reached")
+ }
+
+ func testAutomaticPaymentAsksWithoutLimitReachedWhenOnlyAnAllowanceWithUncheckedLimitsFits() async throws {
+ let candidateSets: [(name: String, unchecked: Paykit.AllowanceAccountingBlock?)] = [
+ ("unchecked candidate unblocked", nil),
+ ("unchecked candidate blocked", .sharedRule(code: "no_eligible_endpoint")),
+ ]
+
+ for candidateSet in candidateSets {
+ let harness = AllowanceHarness()
+ await harness.sdk.setCandidates(Self.checkedAndUncheckedCandidates(
+ checked: .sharedRule(code: "amount_outside_range"),
+ unchecked: candidateSet.unchecked
+ ))
+ let events = AllowanceEventRecorder()
+
+ let result = try await harness.executor.autoPay(
+ Fixtures.paymentRequest(amount: "0.0002"),
+ allowances: Self.checkedAndUncheckedAllowances(),
+ identity: Fixtures.identityKey
+ )
+
+ XCTAssertEqual(result, .manual, candidateSet.name)
+ XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically"), candidateSet.name)
+ XCTAssertTrue(events.events.isEmpty, "\(candidateSet.name): a request an allowance still fits raises no Limit Reached")
+ }
+ }
+
+ func testACheckedAllowanceTheSdkBlocksStaysManualWithoutLimitReachedWhenItFits() async throws {
+ let harness = AllowanceHarness()
+ let lowCapId = "allowance-low-cap"
+ await harness.sdk.setCandidates([
+ AllowanceHarness.candidate(blocked: .sharedRule(code: "no_eligible_endpoint")),
+ Paykit.AllowanceCandidate(allowanceId: lowCapId, eligiblePaymentEndpointIdentifiers: [Fixtures.lightningIdentifier], blocked: nil),
+ ])
+ let events = AllowanceEventRecorder()
+
+ let result = try await harness.executor.autoPay(
+ Fixtures.paymentRequest(amount: "0.00002"),
+ allowances: [Fixtures.allowance(), Fixtures.allowance(allowanceId: lowCapId, perPaymentMaxSats: 1000)],
+ identity: Fixtures.identityKey
+ )
+
+ XCTAssertEqual(result, .manual)
+ XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically"))
+ XCTAssertTrue(events.events.isEmpty, "A checked allowance that fits but the SDK blocks raises no Limit Reached")
+ }
+
+ func testAllowancesThatAllEndedHereHandTheRequestBackWithoutLimitReached() async throws {
+ let request = try Fixtures.paymentRequest(amount: "0.0002")
+ let ended = PaykitAllowanceLocalState(endedAllowanceIds: [PaykitAllowance.scopedId(
+ counterparty: Fixtures.counterpartyKey,
+ allowanceId: Fixtures.walletAllowanceId
+ )])
+ let events = AllowanceEventRecorder()
+ let handBack = AllowanceHarness()
+ handBack.store.seed(ended, identity: Fixtures.identityKey)
+ let online = AllowanceHarness()
+ online.store.seed(ended, identity: Fixtures.identityKey)
+ await online.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range"))])
+
+ let isManual = await handBack.executor.exceedsLimits(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+ let result = await online.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey)
+
+ XCTAssertTrue(isManual)
+ XCTAssertEqual(result, .manual)
+ XCTAssertTrue(events.events.isEmpty, "An allowance ended here raises no Limit Reached, even while the SDK still lists it")
+ }
+
+ func testAutomaticPaymentPaysThroughTheAllowanceWithCheckedLimitsWhenItFits() async throws {
+ let harness = AllowanceHarness()
+ await harness.sdk.setCandidates(Self.checkedAndUncheckedCandidates(checked: nil, unchecked: nil).reversed())
+
+ let isManual = try await harness.executor.exceedsLimits(
+ Fixtures.paymentRequest(),
+ allowances: Self.checkedAndUncheckedAllowances(),
+ identity: Fixtures.identityKey
+ )
+ let result = try await harness.executor.autoPay(
+ Fixtures.paymentRequest(),
+ allowances: Self.checkedAndUncheckedAllowances(),
+ identity: Fixtures.identityKey
+ )
+
+ XCTAssertFalse(isManual, "A request a checked allowance fits waits for the node")
+ XCTAssertEqual(result, .started)
+ let selections = await harness.sdk.selections
+ XCTAssertEqual(selections.map(\.allowanceId), [Fixtures.walletAllowanceId])
+ }
+
+ func testARequestNoAllowanceFitsRaisesLimitReached() async throws {
+ let unchecked = [Fixtures.allowance(perPaymentMaxSats: 50000, hasUncheckedLimits: true)]
+ let request = try Fixtures.paymentRequest(amount: "0.001")
+ let handBack = AllowanceHarness()
+ let events = AllowanceEventRecorder()
+
+ let isManual = await handBack.executor.exceedsLimits(request, allowances: unchecked, identity: Fixtures.identityKey)
+ let handBackEvents = events.events
+ let online = AllowanceHarness()
+ let result = await online.executor.autoPay(request, allowances: unchecked, identity: Fixtures.identityKey)
+
+ XCTAssertTrue(isManual)
+ XCTAssertEqual(handBackEvents, [.limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 100_000)])
+ XCTAssertEqual(result, .manual)
+ XCTAssertEqual(events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 100_000) }.count, 2)
+ }
+
+ /// A Bitkit-style allowance with a 5,000-sat cap and another with a 50,000-sat cap and limits this wallet does not check.
+ private static func checkedAndUncheckedAllowances() -> [PaykitAllowance] {
+ [
+ Fixtures.allowance(),
+ Fixtures.allowance(allowanceId: Fixtures.otherAllowanceId, perPaymentMaxSats: 50000, monthlyLimitSats: 500_000, hasUncheckedLimits: true),
+ ]
+ }
+
+ private static func checkedAndUncheckedCandidates(
+ checked: Paykit.AllowanceAccountingBlock?,
+ unchecked: Paykit.AllowanceAccountingBlock?
+ ) -> [Paykit.AllowanceCandidate] {
+ [
+ AllowanceHarness.candidate(blocked: checked),
+ Paykit.AllowanceCandidate(
+ allowanceId: Fixtures.otherAllowanceId,
+ eligiblePaymentEndpointIdentifiers: [Fixtures.lightningIdentifier],
+ blocked: unchecked
+ ),
+ ]
+ }
+
+ private static func walletPayload(paymentState: PaykitPaymentStateBackup? = nil, sdkState: String? = nil) -> WalletBackupV1 {
+ WalletBackupV1(
+ version: 1,
+ createdAt: 1,
+ transfers: [],
+ privatePaykitHighestReservedReceiveIndexByAddressType: nil,
+ paykitSdkBackupState: sdkState,
+ watchOnlyAccounts: nil,
+ watchOnlyAccountAllocationState: nil,
+ paykitPaymentState: paymentState
+ )
+ }
+
+ // MARK: Limits
+
+ func testOnlyAllowancesWithLimitsThisWalletChecksPayAutomatically() async throws {
+ let cases: [(name: String, terms: Paykit.AllowanceTerms, expected: PaykitAllowanceAutoPayResult)] = try [
+ ("Bitkit's own monthly terms", Fixtures.standardTerms(), .started),
+ ("a lifetime limit", Fixtures.customTerms(lifetimeAmountLimit: "0.001"), .manual),
+ ("a payment count limit", Fixtures.customTerms(monthlyPaymentCountLimit: 3), .manual),
+ ("another period", Fixtures.customTerms(otherPeriodLimits: [Fixtures.weeklyLimit()]), .manual),
+ ]
+
+ for testCase in cases {
+ let harness = AllowanceHarness()
+ let events = AllowanceEventRecorder()
+ let allowance = try XCTUnwrap(PaykitAllowance(record: Fixtures.record(terms: testCase.terms)), testCase.name)
+
+ let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [allowance], identity: Fixtures.identityKey)
+
+ XCTAssertEqual(result, testCase.expected, testCase.name)
+ if testCase.expected == .manual {
+ XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically"), testCase.name)
+ XCTAssertFalse(harness.log.entries.contains("resolve"), testCase.name)
+ XCTAssertTrue(events.events.isEmpty, "\(testCase.name) raises no Limit Reached")
+ }
+ }
+ }
+
func testAnAutomaticPaymentRaisesTheLedgerFloor() async throws {
let harness = AllowanceHarness()
await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 7))
@@ -2298,6 +2674,7 @@ private final class AllowanceTestClock: @unchecked Sendable {
private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sendable {
private let lock = NSLock()
private var states: [String: PaykitAllowanceLocalState] = [:]
+ private var pausedAfterRestore = false
private var savesFail = false
private var savesFailOnceSpent = false
private var failingSaves = 0
@@ -2319,6 +2696,14 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen
}
}
+ func isPausedAfterRestore() throws -> Bool {
+ lock.withLock { pausedAfterRestore }
+ }
+
+ func setPausedAfterRestore(_ isPaused: Bool) throws {
+ lock.withLock { pausedAfterRestore = isPaused }
+ }
+
func failSaves() {
lock.withLock { savesFail = true }
}
@@ -2341,6 +2726,22 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen
}
}
+private struct AllowanceFailingPauseStore: PaykitAllowanceStoring {
+ func load(identity _: String) throws -> PaykitAllowanceLocalState {
+ PaykitAllowanceLocalState()
+ }
+
+ func save(_: PaykitAllowanceLocalState, identity _: String) throws {}
+
+ func isPausedAfterRestore() throws -> Bool {
+ false
+ }
+
+ func setPausedAfterRestore(_: Bool) throws {
+ throw AllowanceMockError.unsupported
+ }
+}
+
private final class AllowanceEventRecorder: @unchecked Sendable {
private let lock = NSLock()
private var storage: [PaykitAllowanceEvent] = []
@@ -2678,6 +3079,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling {
private var peers: [LinkedPeerRecord] = []
private var accountingState: Paykit.AllowanceAccountingState? = PaykitAllowanceFixtures.accountingState()
private var queuedAccountingStates: [Paykit.AllowanceAccountingState?] = []
+ private var accountingError: Error?
private var candidates: [Paykit.AllowanceCandidate] = [AllowanceHarness.candidate()]
private var automaticReservation: Paykit.PaymentAttemptDecision?
private var manualReservation: Paykit.PaymentAttemptDecision?
@@ -2718,6 +3120,10 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling {
queuedAccountingStates = states
}
+ func setAccountingError(_ error: Error?) {
+ accountingError = error
+ }
+
func setCandidates(_ candidates: [Paykit.AllowanceCandidate]) {
self.candidates = candidates
}
@@ -2838,6 +3244,9 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling {
func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? {
log.append("allowanceAccountingState")
+ if let accountingError {
+ throw accountingError
+ }
if !queuedAccountingStates.isEmpty {
accountingState = queuedAccountingStates.removeFirst()
}
diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift
index 4ad7e4559..62676a27f 100644
--- a/BitkitTests/PaykitAllowanceTests.swift
+++ b/BitkitTests/PaykitAllowanceTests.swift
@@ -280,6 +280,47 @@ final class PaykitAllowanceTests: XCTestCase {
XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: nil)))
}
+ func testRecordFlagsLimitsBitkitDoesNotCheckItself() throws {
+ func hasUncheckedLimits(_ terms: Paykit.AllowanceTerms, localRole: Paykit.AllowanceLocalRole = .allower) throws -> Bool {
+ try XCTUnwrap(PaykitAllowance(record: Fixtures.record(localRole: localRole, terms: terms))).hasUncheckedLimits
+ }
+
+ XCTAssertFalse(try hasUncheckedLimits(Fixtures.standardTerms()), "Bitkit's own terms pay automatically")
+ XCTAssertFalse(try hasUncheckedLimits(Fixtures.customTerms()))
+ XCTAssertTrue(try hasUncheckedLimits(Fixtures.customTerms(lifetimeAmountLimit: "0.001")))
+ XCTAssertTrue(try hasUncheckedLimits(Fixtures.customTerms(monthlyPaymentCountLimit: 3)))
+ XCTAssertTrue(try hasUncheckedLimits(Fixtures.customTerms(otherPeriodLimits: [Fixtures.weeklyLimit()])))
+ let secondMonthly = try Paykit.AllowancePeriodLimit(
+ amountLimit: "0.0001",
+ paymentCountLimit: nil,
+ period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-15T00:00:00Z")
+ )
+ XCTAssertTrue(try hasUncheckedLimits(Fixtures.customTerms(otherPeriodLimits: [secondMonthly])))
+ XCTAssertTrue(try hasUncheckedLimits(Fixtures.customTerms(lifetimeAmountLimit: "0.001"), localRole: .allowee))
+ }
+
+ func testTheWalletRestorePauseStaysUntilItIsCleared() throws {
+ try? Keychain.delete(key: .paykitAllowancesPausedAfterRestore)
+ try? Keychain.delete(key: .paykitAllowanceState)
+ defer {
+ try? Keychain.delete(key: .paykitAllowancesPausedAfterRestore)
+ try? Keychain.delete(key: .paykitAllowanceState)
+ }
+ let notPaused = try PaykitAllowanceKeychainStore().isPausedAfterRestore()
+
+ try PaykitAllowanceKeychainStore().setPausedAfterRestore(true)
+ PaykitAllowanceKeychainStore().restoreBackup(PaykitAllowanceStateBackups([
+ Fixtures.identityKey: PaykitAllowanceStateBackup(PaykitAllowanceLocalState(ledgerRevisionFloor: 5)),
+ ]))
+ let pausedAfterReload = try PaykitAllowanceKeychainStore().isPausedAfterRestore()
+ try PaykitAllowanceKeychainStore().setPausedAfterRestore(false)
+ let resumed = try PaykitAllowanceKeychainStore().isPausedAfterRestore()
+
+ XCTAssertFalse(notPaused)
+ XCTAssertTrue(pausedAfterReload)
+ XCTAssertFalse(resumed)
+ }
+
func testStatusMapsEveryLifecycleState() throws {
func status(
_ state: Paykit.AllowanceLifecycleState,
@@ -675,18 +716,24 @@ enum PaykitAllowanceFixtures {
try limits.terms(monthAnchor: septemberAnchor, allowedPaymentEndpointIdentifiers: [lightningIdentifier])
}
- static func customTerms(activeFrom: Date? = nil, expiresAt: Date? = nil) throws -> Paykit.AllowanceTerms {
+ static func customTerms(
+ activeFrom: Date? = nil,
+ expiresAt: Date? = nil,
+ monthlyPaymentCountLimit: UInt64? = nil,
+ otherPeriodLimits: [Paykit.AllowancePeriodLimit] = [],
+ lifetimeAmountLimit: String? = 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,
+ paymentCountLimit: monthlyPaymentCountLimit,
period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-01T00:00:00Z")
),
- ],
- lifetimeAmountLimit: nil,
+ ] + otherPeriodLimits,
+ lifetimeAmountLimit: lifetimeAmountLimit,
activeFrom: activeFrom.map(PaykitAllowanceTime.format),
expiresAt: expiresAt.map(PaykitAllowanceTime.format),
allowedPaymentEndpointIdentifiers: [lightningIdentifier]
@@ -761,7 +808,8 @@ enum PaykitAllowanceFixtures {
monthlyLimitSats: UInt64? = 50000,
monthlyAnchor: Date? = septemberAnchor,
expiresAt: Date? = nil,
- isProposedByMe: Bool? = nil
+ isProposedByMe: Bool? = nil,
+ hasUncheckedLimits: Bool = false
) -> PaykitAllowance {
PaykitAllowance(
id: PaykitAllowance.ID(counterparty: counterparty, allowanceId: allowanceId),
@@ -772,7 +820,16 @@ enum PaykitAllowanceFixtures {
monthlyLimitSats: monthlyLimitSats,
monthlyAnchor: monthlyAnchor,
expiresAt: expiresAt,
- allowedPaymentEndpointIdentifiers: [lightningIdentifier]
+ allowedPaymentEndpointIdentifiers: [lightningIdentifier],
+ hasUncheckedLimits: hasUncheckedLimits
+ )
+ }
+
+ static func weeklyLimit() throws -> Paykit.AllowancePeriodLimit {
+ try Paykit.AllowancePeriodLimit(
+ amountLimit: "0.0001",
+ paymentCountLimit: nil,
+ period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "week", anchor: "2026-09-01T00:00:00Z")
)
}
diff --git a/changelog.d/next/1214.fixed.md b/changelog.d/next/1214.fixed.md
new file mode 100644
index 000000000..4b7c11bb9
--- /dev/null
+++ b/changelog.d/next/1214.fixed.md
@@ -0,0 +1 @@
+Automatic allowance payments stay paused after a wallet restore until you resume them, and requests under an allowance with a lifetime, payment count or additional period limit always ask for your approval.
diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md
index bceaeee65..a52d850ce 100644
--- a/journeys/allowances/README.md
+++ b/journeys/allowances/README.md
@@ -7,6 +7,8 @@ pins the one rule that must never break: a payment interrupted mid-flight is nev
The PIN journey checks that the payer's grant, accept and end ask for the payment PIN when the
user requires one, while automatic payments never prompt.
The restore journey checks that a payer restored from its recovery phrase keeps its allowance history.
+`allowance-paused-after-restore.xml` checks that the restored payer pays nothing automatically until it
+taps Resume, also after a relaunch.
## Setup
@@ -30,6 +32,8 @@ UI snapshot cannot see.
Request sheet for the same request opens at the same time; behind the PIN screen the sheet waits, so
the notification is posted.
+A wallet restore leaves automatic payments paused until Resume. Journeys that restore, such as `restore-keeps-history.xml`, end with automatic payments paused, so tap Resume in Allowances before running an automatic payment journey on the same instance.
+
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, priced in bitcoin. On Android, enter the amount in sats:
@@ -63,7 +67,8 @@ killed right after handing the payment to the node never paid twice after relaun
- Payments tab: `Tab-payments`, `PaymentRequestRequestPayment`,
`PaymentRequestRow--one-time` whose subtitle ends with "· Auto-paid" for an
automatic payment
-- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet`
+- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet`, or `PaymentRequestConfirm`
+ when the request opens straight on its confirm screen
The payee's wallet accepts the payer's offer by itself once the offer has arrived, which can take a
while between contacts that have never exchanged a private message, and posts " set up an
diff --git a/journeys/allowances/allowance-paused-after-restore.xml b/journeys/allowances/allowance-paused-after-restore.xml
new file mode 100644
index 000000000..eb0282fbd
--- /dev/null
+++ b/journeys/allowances/allowance-paused-after-restore.xml
@@ -0,0 +1,31 @@
+
+
+ Automatic payments stay paused after a payer restores its wallet from its recovery phrase, until
+ the payer resumes them: the Allowances tab explains the pause, a request within the limits asks
+ for approval instead of paying, the pause survives a relaunch, and Resume turns automatic
+ payments back on. Reset And Restore wipes the keychain and reads the allowance state back from
+ the wallet backup, so no second install is needed. Requires the Active allowance from
+ set-and-accept.xml and the payer's notification permission granted. When the payer requires a
+ PIN for payments, Resume asks for it, as in pin-gates-allowance-changes.xml. The journey wipes
+ the payer half way through, so do not run it against a wallet whose funds matter.
+
+
+ Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted
+ On the payer instance, open the menu, navigate to Settings, then the "Security" tab (id "Tab-security"), then tap "Back Up Your Money" (id "BackupWallet"), continue past the intro (id "BackupIntroViewContinue"), tap to reveal the phrase (id "TapToReveal") and record the phrase from the word grid (id "SeedContainer")
+ Open Settings, then the "Security" tab, then "Data Backups" (id "BackupSettings"), and wait on the list (id "BackupScrollView") until the "Boosts & Transfers" row reports a backup made after the allowance became Active — that row carries no id, so read its text
+ Open Settings, then the "Security" tab, then "Reset And Restore" (id "ResetAndRestore"), tap "Reset Wallet" and confirm with "Yes, Reset" in the alert — neither carries an id, so tap them by label
+ On the onboarding screen tap "Restore" (id "RestoreWallet"), then "MultipleDevices-button", enter the recorded words into the word inputs (id "Word-0" onwards) and tap Restore (id "RestoreButton")
+ Wait for the restore to finish and tap "Get Started" (id "GetStartedButton")
+ Open the drawer menu, tap Subscriptions and tap the Allowances tab (id "Tab-allowances")
+ Verify the paused notice (id "AllowancesPaused") reads "Automatic payments paused" and "This wallet was restored from a backup. Requests ask for your approval until you resume.", shows Resume (id "AllowancesResume"), and the payee's row (id "AllowanceRow-<entryId>") is listed as Active
+ On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (id "Tab-payments") and send the payer a one-time Payment Request for $2 with the note "Paused"
+ Switch to the payer instance, with Bitkit in the foreground, and verify the request waits for approval: its confirm screen (id "PaymentRequestConfirm") opens with "Paused" (id "PaymentRequestFor"), or the Payment Request sheet (id "PaymentRequestsSheet") opens for the request, or the bell (id "PaymentRequestsBell") shows one pending request, and that no "Payment Executed" notification is posted, checking banners and Bitkit's entries in Notification Center
+ Pay it from the confirm screen, or from the sheet (id "PaymentRequestPay-<paymentRequestId>") when the sheet shows, and complete Swipe To Pay, then verify Bitcoin Sent shows about $2.00
+ Relaunch the payer with `xcrun simctl terminate <udid> to.bitkit` and `xcrun simctl launch <udid> to.bitkit`, open the drawer menu, tap Subscriptions and tap the Allowances tab
+ Verify the paused notice (id "AllowancesPaused") still reads "This wallet was restored from a backup. Requests ask for your approval until you resume."
+ Tap Resume (id "AllowancesResume") and, if the PIN pad shows, enter the PIN
+ Verify the paused notice (id "AllowancesPaused") is gone and the payee's row is still listed as Active
+ On the payee instance, send the payer a one-time Payment Request for $2 with the note "Resumed"
+ Switch to the payer instance, with Bitkit in the foreground, and verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted, checking the banner or Bitkit's entries in Notification Center
+
+