From f4b560aa5f91b10b1890dac40d206d8c859fb8b4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 13:09:03 -0500 Subject: [PATCH 01/11] fix: pause automatic payments after a wallet restore Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/repositories/BackupRepo.kt | 2 + .../repositories/PaykitAllowanceExecutor.kt | 14 +++++ .../repositories/PaykitAllowanceRepo.kt | 46 ++++++++++++++--- .../repositories/PaykitAllowanceStore.kt | 22 ++++++++ .../screens/subscriptions/AllowancesScreen.kt | 44 ++++++++++++++-- .../subscriptions/AllowancesViewModel.kt | 9 ++-- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/repositories/BackupRepoTest.kt | 31 +++++++++++ .../PaykitAllowanceExecutorTest.kt | 32 ++++++++++++ .../repositories/PaykitAllowanceRepoTest.kt | 51 +++++++++++++++++++ .../repositories/PaykitAllowanceStoreTest.kt | 41 +++++++++++++++ .../subscriptions/AllowancesScreenTest.kt | 24 ++++++++- .../subscriptions/AllowancesViewModelTest.kt | 2 +- .../allowance-pause-after-restore.fixed.md | 1 + journeys/allowances/README.md | 2 + .../allowance-paused-after-restore.xml | 31 +++++++++++ 31 files changed, 349 insertions(+), 19 deletions(-) create mode 100644 changelog.d/next/allowance-pause-after-restore.fixed.md create mode 100644 journeys/allowances/allowance-paused-after-restore.xml diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index c3dde00481..7a315cf9d1 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -1153,6 +1153,8 @@ class BackupRepo @Inject constructor( paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) paykitAllowanceStore.restoreBackup(allowances) } + // Automatic allowance payments wait for the user to resume them; restored spending still counts. + paykitAllowanceStore.pauseAfterRestore() db.transferDao().upsert(parsed.transfers) watchOnlyAccountRepo.restore( parsed.watchOnlyAccounts.orEmpty(), diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 1c72fe4bae..8676d991b5 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -324,6 +324,14 @@ class PaykitAllowanceExecutor @Inject constructor( updateLocalStateDurably(identity) { it.copy(ledgerRevisionFloor = ledger?.revision) } } + /** True after a wallet restore until [resumeAfterRestore]; every request asks for approval meanwhile. */ + fun isPausedAfterRestore(): Boolean = store.isPausedAfterRestore() + + /** Lets automatic payments run again after a wallet restore. The restored spends still count toward the limits. */ + suspend fun resumeAfterRestore(): Unit = withContext(ioDispatcher) { + stateMutex.withLock { store.resumeAfterRestore() } + } + // endregion // region Ledger @@ -445,6 +453,8 @@ class PaykitAllowanceExecutor @Inject constructor( PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } if (matching.isEmpty()) return@withContext false + // Paused automatic payments keep the request manual, without waiting for the node. + if (isPausedAfterRestore()) return@withContext true val read = runSuspendCatching { paykitSdkService.allowanceAccountingState() } // A ledger behind this wallet keeps the request manual, without waiting for the node. if (read.isSuccess && runSuspendCatching { !acceptsLedger(read.getOrNull(), identity) }.getOrDefault(true)) { @@ -589,6 +599,10 @@ class PaykitAllowanceExecutor @Inject constructor( val activation = activeIdentity try { + if (isPausedAfterRestore()) { + Logger.info("Kept an incoming request manual: paused after a wallet restore", context = TAG) + return@withContext PaykitAllowanceAutoPayResult.MANUAL + } runSuspendCatching { ensureReconciled(identity) // Survive a cancelled caller: stopping after the acceptance would leave the request unpaid. diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index be283f9cf6..75299b1d44 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -54,6 +54,15 @@ sealed interface PaykitAllowanceEvent { data object LedgerChanged : PaykitAllowanceEvent } +/** Why automatic payments wait for the user to resume them; every covered request asks for approval meanwhile. */ +enum class PaykitAllowancePause { + /** The allowance ledger is behind one this wallet has read. */ + LEDGER_BEHIND, + + /** The wallet was restored from a backup. */ + RESTORED, +} + enum class PaykitAllowanceAutoPayResult { /** No accepted Allowance covers the request's contact, or the request is already being paid. */ NOT_COVERED, @@ -98,6 +107,9 @@ class PaykitAllowanceRepo @Inject constructor( /** A request created up to this long before its Allowance was accepted still counts as created after it. */ val ACCEPTANCE_CLOCK_TOLERANCE = 30.seconds + /** Lifecycle states of an allowance that pays, or may come to pay, automatically. */ + private val PAYABLE_STATES = setOf(AllowanceLifecycleState.PROPOSED, AllowanceLifecycleState.ACCEPTED) + /** Endpoints Bitkit pays automatically: bolt11 and every on-chain method of the network. */ fun allowedPaymentEndpointIdentifiers(network: Network = Env.network): List = (listOf(MethodId.Bolt11) + MethodId.entries.filter { it.isOnchain }).map { it.rawValueForNetwork(network) } @@ -135,10 +147,13 @@ class PaykitAllowanceRepo @Inject constructor( /** Sats paid automatically per entry id, for "Paid so far". */ val autoPaidSats: StateFlow> = _autoPaidSats.asStateFlow() - private val _isAutomaticPaymentPaused = MutableStateFlow(false) + private val _automaticPaymentPause = MutableStateFlow(null) - /** True while the allowance ledger is behind one this wallet has read, until [resumeAutomaticPayments]. */ - val isAutomaticPaymentPaused: StateFlow = _isAutomaticPaymentPaused.asStateFlow() + /** + * Why automatic payments are paused until [resumeAutomaticPayments], or null while they run. A ledger behind this + * wallet comes first; a restore pauses them only while this wallet pays through an allowance. + */ + val automaticPaymentPause: StateFlow = _automaticPaymentPause.asStateFlow() val events: SharedFlow = executor.events @@ -224,7 +239,7 @@ class PaykitAllowanceRepo @Inject constructor( manualRequestSignatures.clear() _offersToAnnounce.update { emptyList() } announcedOfferKeys.clear() - _isAutomaticPaymentPaused.update { false } + _automaticPaymentPause.update { null } publish(emptyList(), PaykitAllowanceLocalState()) } @@ -234,16 +249,27 @@ class PaykitAllowanceRepo @Inject constructor( refreshMutex.withLock { val listing = listAllowances() listing.onSuccess { executor.rememberEndedAllowances(identity, it) } - val isPaused = executor.isLedgerBehind(identity) + val allowances = listing.getOrDefault(_allowances.value) + val pause = automaticPaymentPause(identity, allowances) if (activeIdentity == identity) { - _isAutomaticPaymentPaused.update { isPaused } - publish(listing.getOrDefault(_allowances.value), executor.localState(identity)) + _automaticPaymentPause.update { pause } + publish(allowances, executor.localState(identity)) } listing.map {} } } } + private suspend fun automaticPaymentPause( + identity: String, + allowances: List, + ): PaykitAllowancePause? = when { + executor.isLedgerBehind(identity) -> PaykitAllowancePause.LEDGER_BEHIND + executor.isPausedAfterRestore() && allowances.any { it.isAllower && it.lifecycleState in PAYABLE_STATES } -> + PaykitAllowancePause.RESTORED + else -> null + } + private suspend fun whileUpdating(block: suspend () -> T): T { val release = beginCoverageUpdate() return try { @@ -431,11 +457,15 @@ class PaykitAllowanceRepo @Inject constructor( paykitSdkService.rejectAllowance(it.counterparty, it.allowanceId) } - /** Resumes automatic payments from the ledger read now; see [PaykitAllowanceExecutor.acceptCurrentLedger]. */ + /** + * Resumes automatic payments from the ledger read now and ends the pause after a wallet restore; see + * [PaykitAllowanceExecutor.acceptCurrentLedger]. + */ suspend fun resumeAutomaticPayments(): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable executor.acceptCurrentLedger(identity) + executor.resumeAfterRestore() executor.recover(identity) refresh() Unit diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt index 1cbbc2da13..03097db4a8 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -104,6 +104,8 @@ class PaykitAllowanceStore @Inject constructor( @Serializable private data class Stored( val statesByIdentity: Map = emptyMap(), + /** Set by a wallet restore and kept out of the backup; cleared when the user resumes automatic payments. */ + val isPausedAfterRestore: Boolean = false, ) private val _backupStateVersion = MutableStateFlow(0L) @@ -149,6 +151,26 @@ class PaykitAllowanceStore @Inject constructor( _backupStateVersion.update { it + 1 } } + /** True from a wallet restore until the user resumes automatic payments, also across app restarts. */ + fun isPausedAfterRestore(): Boolean = + runCatching { loadAll().isPausedAfterRestore } + .onFailure { Logger.warn("Failed to load Paykit allowance state", it, context = TAG) } + .getOrDefault(false) + + /** Pauses automatic payments of every identity after a wallet restore, until [resumeAfterRestore]. */ + suspend fun pauseAfterRestore() { + // A restore may replace state this build cannot read, as restoreBackup does. + val stored = runCatching { loadAll() }.getOrDefault(Stored()) + keychain.upsertString(KEY, storeJson.encodeToString(stored.copy(isPausedAfterRestore = true))) + } + + /** Clears the pause [pauseAfterRestore] set. Throws on state this build cannot read, which it never replaces. */ + suspend fun resumeAfterRestore() { + val stored = loadAll() + if (!stored.isPausedAfterRestore) return + keychain.upsertString(KEY, storeJson.encodeToString(stored.copy(isPausedAfterRestore = false))) + } + private fun PaykitAllowanceLocalState.withoutWatermark() = copy(lastTrustedTimeMillis = null) // Throws on state this build cannot read, so no save replaces every identity's journal with one. diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt index fb6518da26..d9d399ce7e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesScreen.kt @@ -44,6 +44,7 @@ import to.bitkit.R import to.bitkit.models.PubkyProfile import to.bitkit.models.USD_SYMBOL import to.bitkit.repositories.PaykitAllowance +import to.bitkit.repositories.PaykitAllowancePause import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -167,9 +168,13 @@ private fun AllowancesContent( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxSize() ) { - if (uiState.isAutomaticPaymentPaused) { + uiState.automaticPaymentPause?.let { pause -> item(key = "paused") { - AutomaticPaymentsPaused(isWorking = uiState.isWorking, onClickResume = onClickResume) + AutomaticPaymentsPaused( + pause = pause, + isWorking = uiState.isWorking, + onClickResume = onClickResume, + ) } } items(uiState.allowances, key = { it.id }) { allowance -> @@ -228,7 +233,11 @@ private fun AllowancesEmptyState(modifier: Modifier = Modifier) { } @Composable -private fun AutomaticPaymentsPaused(isWorking: Boolean, onClickResume: () -> Unit) { +private fun AutomaticPaymentsPaused( + pause: PaykitAllowancePause, + isWorking: Boolean, + onClickResume: () -> Unit, +) { Column( modifier = Modifier .fillMaxWidth() @@ -239,7 +248,15 @@ private fun AutomaticPaymentsPaused(isWorking: Boolean, onClickResume: () -> Uni ) { BodyMSB(text = stringResource(R.string.subscriptions__allowances_paused_title)) VerticalSpacer(4.dp) - BodyS(text = stringResource(R.string.subscriptions__allowances_paused_description), color = Colors.White64) + BodyS( + text = stringResource( + when (pause) { + PaykitAllowancePause.LEDGER_BEHIND -> R.string.subscriptions__allowances_paused_description + PaykitAllowancePause.RESTORED -> R.string.subscriptions__allowances_paused_restore_description + } + ), + color = Colors.White64, + ) VerticalSpacer(16.dp) SecondaryButton( text = stringResource(R.string.subscriptions__allowances_paused_resume), @@ -350,7 +367,7 @@ private fun Preview2() { AllowancesContent( uiState = AllowancesUiState( isLoaded = true, - isAutomaticPaymentPaused = true, + automaticPaymentPause = PaykitAllowancePause.LEDGER_BEHIND, allowances = persistentListOf( previewAllowance(), previewAllowance(id = "allowance-2", name = "Mia").copy( @@ -371,3 +388,20 @@ private fun Preview2() { ) } } + +@Preview(showSystemUi = true) +@Composable +private fun PreviewRestored() { + AppThemeSurface { + AllowancesContent( + uiState = AllowancesUiState( + isLoaded = true, + automaticPaymentPause = PaykitAllowancePause.RESTORED, + allowances = persistentListOf(previewAllowance()), + ), + topPadding = 0.dp, + onClickAdd = {}, + onClickAllowance = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt index e54ff2b51e..2428819918 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModel.kt @@ -39,6 +39,7 @@ import to.bitkit.repositories.PaykitAllowance import to.bitkit.repositories.PaykitAllowanceEntry import to.bitkit.repositories.PaykitAllowanceError import to.bitkit.repositories.PaykitAllowanceLimits +import to.bitkit.repositories.PaykitAllowancePause import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitPaymentRequestRepo @@ -110,15 +111,15 @@ class AllowancesViewModel @Inject constructor( contactChoices, isWorking, authorization, - allowanceRepo.isAutomaticPaymentPaused, - ) { allowances, contacts, isWorking, authorization, isPaused -> + allowanceRepo.automaticPaymentPause, + ) { allowances, contacts, isWorking, authorization, pause -> AllowancesUiState( isLoaded = true, allowances = allowances, contacts = contacts, isWorking = isWorking, authorization = authorization, - isAutomaticPaymentPaused = isPaused, + automaticPaymentPause = pause, ) }.stateIn( scope = viewModelScope, @@ -348,7 +349,7 @@ data class AllowancesUiState( val contacts: ImmutableList = persistentListOf(), val isWorking: Boolean = false, val authorization: AllowanceAuthorization? = null, - val isAutomaticPaymentPaused: Boolean = false, + val automaticPaymentPause: PaykitAllowancePause? = null, ) /** A pending PIN or biometric check before a gated allowance action. */ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index c8973f2586..b9c3b6934f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1447,6 +1447,7 @@ يمكنك تعيين حدود للإنفاق لتلبية طلبات الدفع من جهات الاتصال الموثوقة تلقائيًا. أعدّ\n<accent>الميزانيات</accent> سجلات الميزانيات لديك أقدم من سجلات استخدمتها هذه المحفظة من قبل. ستطلب الطلبات موافقتك حتى تستأنف. + تمت استعادة هذه المحفظة من نسخة احتياطية. ستطلب الطلبات موافقتك حتى تستأنف. استئناف تم إيقاف الدفعات التلقائية مؤقتًا إلغاء الاشتراك diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index f62df577aa..5593a48af0 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1462,6 +1462,7 @@ Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza. Configure\n<accent>asignaciones</accent> 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. + Esta billetera se restauró desde una copia de seguridad. Las solicitudes pedirán tu aprobación hasta que reanudes. Reanudar Pagos automáticos en pausa Cancelar suscripción diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index f87eac631f..7be0f07491 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1459,6 +1459,7 @@ Pots establir límits de despesa per pagar automàticament les sol·licituds de pagament de contactes de confiança. Configura\n<accent>assignacions</accent> 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. + Aquest moneder s\'ha restaurat des d\'una còpia de seguretat. Les sol·licituds demanaran la teva aprovació fins que reprenguis. Reprèn Pagaments automàtics en pausa Cancel·lar subscripció diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a6b2454328..4764f05d50 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1461,6 +1461,7 @@ Můžete nastavit limity útraty pro automatické vyřizování žádostí o platbu od důvěryhodných kontaktů. Nastavte si\n<accent>rozpočty</accent> 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. + Tato peněženka byla obnovena ze zálohy. Požadavky budou čekat na vaše schválení, dokud platby neobnovíte. Obnovit Automatické platby pozastaveny Zrušit předplatné diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d8a0d88b77..3a9d91debb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1460,6 +1460,7 @@ Lege Ausgabenlimits fest, um Zahlungsanforderungen von vertrauenswürdigen Kontakten automatisch zu erfüllen. Richte deine\n<accent>Budgets</accent> ein Deine Budgetdaten sind älter als Daten, die diese Wallet bereits verwendet hat. Anfragen warten auf deine Bestätigung, bis du fortsetzt. + Diese Wallet wurde aus einem Backup wiederhergestellt. Anfragen warten auf deine Bestätigung, bis du fortsetzt. Fortsetzen Automatische Zahlungen pausiert Abonnement kündigen diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 5b589e5087..90e6402ac7 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1453,6 +1453,7 @@ Μπορείς να ορίσεις όρια δαπανών για να εκπληρώνονται αυτόματα αιτήματα πληρωμής από αξιόπιστες επαφές. Ρύθμισε\n<accent>προϋπολογισμούς</accent> Τα αρχεία των προϋπολογισμών σου είναι παλαιότερα από αρχεία που έχει ήδη χρησιμοποιήσει αυτό το πορτοφόλι. Τα αιτήματα θα ζητούν την έγκρισή σου μέχρι να συνεχίσεις. + Αυτό το πορτοφόλι επαναφέρθηκε από αντίγραφο ασφαλείας. Τα αιτήματα θα ζητούν την έγκρισή σου μέχρι να συνεχίσεις. Συνέχεια Οι αυτόματες πληρωμές έχουν παυθεί Ακύρωση συνδρομής diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index 34faa374a7..741d63a9b5 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -1457,6 +1457,7 @@ Puedes establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza. Configura\n<accent>asignaciones</accent> 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. + Este monedero se ha restaurado desde una copia de seguridad. Las solicitudes pedirán tu aprobación hasta que reanudes. Reanudar Pagos automáticos en pausa Cancelar suscripción diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b9c24c4522..c029ec5420 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1457,6 +1457,7 @@ Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza. Configure\n<accent>asignaciones</accent> 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. + Este monedero se ha restaurado desde una copia de seguridad. Las solicitudes pedirán tu aprobación hasta que reanudes. Reanudar Pagos automáticos en pausa Cancelar suscripción diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 504d48a804..4cf966f33c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1458,6 +1458,7 @@ Vous pouvez définir des limites de dépenses pour régler automatiquement les demandes de paiement de contacts de confiance. Configurez\n<accent>vos allocations</accent> 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. + Ce portefeuille a été restauré à partir d\'une sauvegarde. Les demandes attendront votre accord jusqu\'à la reprise. Reprendre Paiements automatiques en pause Annuler l\'abonnement diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index bbe3915683..7ef7b7f81a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1461,6 +1461,7 @@ Puoi impostare limiti di spesa per soddisfare automaticamente le richieste di pagamento di contatti fidati. Configura i tuoi\n<accent>budget</accent> 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. + Questo wallet è stato ripristinato da un backup. Le richieste chiederanno la tua approvazione finché non riprendi. Riprendi Pagamenti automatici in pausa Annulla Abbonamento diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 9e476721ba..e0396b716a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1459,6 +1459,7 @@ U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen. Stel\n<accent>toelagen</accent> in Je toelagengegevens zijn ouder dan gegevens die deze wallet al heeft gebruikt. Verzoeken vragen om je goedkeuring tot je hervat. + Deze wallet is hersteld vanuit een back-up. Verzoeken vragen om je goedkeuring tot je hervat. Hervatten Automatische betalingen gepauzeerd Abonnement opzeggen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index db3199891a..c3f3e11741 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1460,6 +1460,7 @@ Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów. Skonfiguruj\n<accent>budżety</accent> 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. + Ten portfel został przywrócony z kopii zapasowej. Prośby będą czekać na Twoją zgodę, dopóki nie wznowisz. Wznów Płatności automatyczne wstrzymane Anuluj subskrypcję diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 47626cd119..1ae6481c71 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1461,6 +1461,7 @@ Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos de confiança. Configure\n<accent>mesadas</accent> 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. + Esta carteira foi restaurada a partir de um backup. As solicitações vão pedir sua aprovação até você retomar. Retomar Pagamentos automáticos pausados Cancelar Assinatura diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1fece40c5e..78ca9f7a42 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1447,6 +1447,7 @@ Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança. Configure\n<accent>mesadas</accent> 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. + Esta carteira foi restaurada a partir de uma cópia de segurança. Os pedidos vão pedir a sua aprovação até retomar. Retomar Pagamentos automáticos em pausa Cancelar subscrição diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c59eb8f103..255755837c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1476,6 +1476,7 @@ Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов. Настройте\n<accent>бюджеты</accent> Данные ваших бюджетов старше тех, что этот кошелёк уже использовал. Запросы будут ждать вашего подтверждения, пока вы не возобновите платежи. + Этот кошелёк был восстановлен из резервной копии. Запросы будут ждать вашего подтверждения, пока вы не возобновите платежи. Возобновить Автоматические платежи приостановлены Отменить Подписку diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0581f041af..c5c9dfea16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1085,6 +1085,7 @@ You can set spending limits to automatically fulfill payment requests from trusted peers. allowances]]> Your allowance records are older than ones this wallet has already used. Requests ask for your approval until you resume. + This wallet was restored from a backup. Requests ask for your approval until you resume. Resume Automatic payments paused Cancel diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 85a4e812de..68a097a432 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -38,6 +38,7 @@ import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -714,6 +715,36 @@ class BackupRepoTest : BaseUnitTest() { verify(paykitAllowanceStore).restoreBackup(mapOf(identity to allowances.restored())) } + @Test + fun `wallet restore pauses automatic allowance payments after restoring their state`() = test { + val identity = "pubky" + "z".repeat(52) + val allowances = PaykitAllowanceStateBackup(ledgerRevisionFloor = 5uL) + stubWalletBackup( + paykitSdkBackupState = "saved-sdk-state", + paykitPaymentState = PaykitPaymentStateBackup( + subscriptions = emptyMap(), + pendingProofs = emptyList(), + allowances = mapOf(identity to allowances), + ), + ) + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + val order = inOrder(paykitAllowanceStore) + order.verify(paykitAllowanceStore).restoreBackup(mapOf(identity to allowances.restored())) + order.verify(paykitAllowanceStore).pauseAfterRestore() + } + + @Test + fun `wallet restore without allowance state still pauses automatic allowance payments`() = test { + stubWalletBackup(paykitSdkBackupState = "saved-sdk-state") + + sut.performFullRestoreFromLatestBackup().getOrThrow() + + verify(paykitAllowanceStore, never()).restoreBackup(any()) + verify(paykitAllowanceStore).pauseAfterRestore() + } + @Test fun `restore without Paykit state drops the ledger floor`() = test { val identity = "pubky" + "z".repeat(52) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index d094fd3e70..054e286118 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -521,6 +521,26 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.STARTED, underLimit) } + @Test + fun `a restored wallet keeps requests manual until automatic payments resume`() = test { + PaykitAllowanceStore(keychain).pauseAfterRestore() + val request = fixtures.paymentRequest() + + val paused = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, paused) + assertTrue(sut.isPausedAfterRestore()) + verify(sdk, never()).evaluateAllowanceCandidates(any(), any()) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any(), any()) + verifyNoInteractions(payer) + + sut.resumeAfterRestore() + val resumed = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertFalse(sut.isPausedAfterRestore()) + assertEquals(PaykitAllowanceAutoPayResult.STARTED, resumed) + } + @Test fun `a ledger that goes behind this wallet during admission keeps the request manual`() = test { sut.updateLocalState(identity) { it.copy(ledgerRevisionFloor = 5uL) } @@ -1398,6 +1418,18 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertTrue(events.isEmpty(), "A ledger behind the wallet raises no Limit Reached") } + @Test + fun `local capacity check hands back a request while paused after a restore`() = test { + collectEvents() + PaykitAllowanceStore(keychain).pauseAfterRestore() + + val isManual = sut.classifyAboveLimit(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertTrue(isManual) + assertTrue(events.isEmpty(), "A restore pause raises no Limit Reached") + verify(sdk, never()).allowanceAccountingState() + } + @Test fun `local capacity check flags only requests that fit no allowance`() = test { collectEvents() diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index 55fc7fc43e..f4c0677612 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -165,6 +165,57 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { verify(sdk, times(2)).listAllowances(any()) } + @Test + fun `a restored wallet pauses automatic payments while it pays an allowance`() = test { + whenever(executor.isPausedAfterRestore()).thenReturn(true) + records = listOf(fixtures.record()) + + sut.activate(identity) + + assertEquals(PaykitAllowancePause.RESTORED, sut.automaticPaymentPause.value) + } + + @Test + fun `a restored wallet shows no pause without an allowance it pays`() = test { + whenever(executor.isPausedAfterRestore()).thenReturn(true) + records = listOf( + fixtures.record(localRole = AllowanceLocalRole.ALLOWEE), + fixtures.record(allowanceId = "allowance-ended", state = AllowanceLifecycleState.ENDED), + ) + + sut.activate(identity) + + assertEquals(2, sut.entries.value.size) + assertNull(sut.automaticPaymentPause.value) + } + + @Test + fun `a ledger behind this wallet explains the pause before a restore does`() = test { + whenever(executor.isPausedAfterRestore()).thenReturn(true) + whenever(executor.isLedgerBehind(identity)).thenReturn(true) + records = listOf(fixtures.record()) + + sut.activate(identity) + + assertEquals(PaykitAllowancePause.LEDGER_BEHIND, sut.automaticPaymentPause.value) + } + + @Test + fun `resuming ends the pause after a restore`() = test { + var isPaused = true + whenever(executor.isPausedAfterRestore()).thenAnswer { isPaused } + whenever(executor.resumeAfterRestore()).doSuspendableAnswer { isPaused = false } + records = listOf(fixtures.record()) + sut.activate(identity) + assertEquals(PaykitAllowancePause.RESTORED, sut.automaticPaymentPause.value) + + sut.resumeAutomaticPayments().getOrThrow() + + verify(executor).acceptCurrentLedger(identity) + verify(executor).resumeAfterRestore() + assertNull(sut.automaticPaymentPause.value) + } + @Test fun `auto-paid sats and requests come from the lasting totals, not the trimmed journal`() = test { records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt index 5414d4687a..b233ab50ab 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt @@ -12,6 +12,8 @@ import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class PaykitAllowanceStoreTest : BaseUnitTest() { private val identity = PaykitAllowanceFixtures.identityKey @@ -71,6 +73,45 @@ class PaykitAllowanceStoreTest : BaseUnitTest() { assertEquals(2, sut.backupSnapshot().size) } + @Test + fun `restore pause survives a reload until resumed`() = test { + sut.save(identity, PaykitAllowanceLocalState(notifiedRequestIds = setOf("request-1"))) + + sut.pauseAfterRestore() + + assertTrue(PaykitAllowanceStore(keychain).isPausedAfterRestore()) + assertEquals(setOf("request-1"), PaykitAllowanceStore(keychain).load(identity).notifiedRequestIds) + + sut.resumeAfterRestore() + + assertFalse(PaykitAllowanceStore(keychain).isPausedAfterRestore()) + assertEquals(setOf("request-1"), sut.load(identity).notifiedRequestIds) + } + + @Test + fun `restore pause stays out of the backup and outlives restored state`() = test { + val restored = PaykitAllowanceLocalState(autoPaidSatsByAllowance = mapOf("key" to 1_000uL)) + sut.pauseAfterRestore() + val before = sut.backupStateVersion.value + + sut.restoreBackup(mapOf(identity to restored)) + sut.save(identity, restored.copy(notifiedRequestIds = setOf("request-1"))) + + assertTrue(sut.isPausedAfterRestore()) + assertEquals(before + 2, sut.backupStateVersion.value) + assertEquals(setOf(PubkyPublicKeyFormat.normalized(identity) ?: identity), sut.backupSnapshot().keys) + } + + @Test + fun `resuming never replaces state this build cannot read`() = test { + stored = "{\"statesByIdentity\":{\"other\":{\"journal\":[{\"stage\":\"from-a-newer-build\"}]}}}" + val unreadable = stored + + assertFalse(sut.isPausedAfterRestore()) + assertFailsWith { sut.resumeAfterRestore() } + assertEquals(unreadable, stored) + } + @Test fun `state saved before the automatic payment checks still loads`() = test { val key = PubkyPublicKeyFormat.normalized(identity) ?: identity diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesScreenTest.kt index 18a74509ad..fa30d70985 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesScreenTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.dp import com.synonym.paykit.AllowanceLifecycleState @@ -30,6 +31,7 @@ import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowSystemClock +import to.bitkit.R import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.env.Env @@ -37,6 +39,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.PaykitAllowance import to.bitkit.repositories.PaykitAllowanceEntry +import to.bitkit.repositories.PaykitAllowancePause import to.bitkit.repositories.PaykitAllowanceRepo import to.bitkit.repositories.PaykitPaymentRequest import to.bitkit.repositories.PaykitPaymentRequestRepo @@ -69,6 +72,7 @@ class AllowancesScreenTest { private val currencyRepo: CurrencyRepo = mock() private val settingsStore: SettingsStore = mock() + private val pause = MutableStateFlow(PaykitAllowancePause.LEDGER_BEHIND) private var isHostShown by mutableStateOf(true) private var allowanceClicks = 0 private lateinit var viewModel: AllowancesViewModel @@ -79,7 +83,7 @@ class AllowancesScreenTest { whenever(app.validatePin(any())).thenReturn(true) whenever(allowanceRepo.entries).thenReturn(MutableStateFlow(listOf(entry()))) whenever(allowanceRepo.autoPaidSats).thenReturn(MutableStateFlow(emptyMap())) - whenever(allowanceRepo.isAutomaticPaymentPaused).thenReturn(MutableStateFlow(true)) + whenever(allowanceRepo.automaticPaymentPause).thenReturn(pause) whenever { allowanceRepo.refresh() }.thenReturn(Result.success(Unit)) whenever { allowanceRepo.resumeAutomaticPayments() }.thenReturn(Result.success(Unit)) whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) @@ -162,6 +166,24 @@ class AllowancesScreenTest { composeRule.onNodeWithTag("N1").assertExists() } + @Test + fun `the pause explains a wallet restore and a ledger behind with their own descriptions`() { + val restored = composeRule.activity.getString(R.string.subscriptions__allowances_paused_restore_description) + val ledgerBehind = composeRule.activity.getString(R.string.subscriptions__allowances_paused_description) + composeRule.onNodeWithText(ledgerBehind).assertExists() + composeRule.onNodeWithText(restored).assertDoesNotExist() + + pause.value = PaykitAllowancePause.RESTORED + + composeRule.onNodeWithText(restored).assertExists() + composeRule.onNodeWithText(ledgerBehind).assertDoesNotExist() + composeRule.onNodeWithTag("AllowancesResume").assertExists() + + pause.value = null + + composeRule.onNodeWithTag("AllowancesPaused").assertDoesNotExist() + } + // Tall enough that the row lies under the empty part of the PIN check, not under its keypad. @Config(qualifiers = "w360dp-h800dp") @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt index d0ca19afe2..9ce6338df6 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/AllowancesViewModelTest.kt @@ -78,7 +78,7 @@ class AllowancesViewModelTest : BaseUnitTest() { whenever(allowanceRepo.entries).thenReturn(entries) whenever(settingsStore.data).thenReturn(settings) whenever(allowanceRepo.autoPaidSats).thenReturn(autoPaidSats) - whenever(allowanceRepo.isAutomaticPaymentPaused).thenReturn(MutableStateFlow(false)) + whenever(allowanceRepo.automaticPaymentPause).thenReturn(MutableStateFlow(null)) whenever(pubkyRepo.contacts).thenReturn(contacts) whenever(paymentRequestRepo.eligibleTargets).thenReturn(targets) whenever(paymentRequestRepo.paymentRequestHistory) diff --git a/changelog.d/next/allowance-pause-after-restore.fixed.md b/changelog.d/next/allowance-pause-after-restore.fixed.md new file mode 100644 index 0000000000..e55d15898c --- /dev/null +++ b/changelog.d/next/allowance-pause-after-restore.fixed.md @@ -0,0 +1 @@ +Automatic allowance payments now stay paused after a wallet restore until you resume them. diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 8d73342f9c..76c85caf63 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -5,6 +5,8 @@ contact, the payee's wallet accepts it by itself, requests within the limits are 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 restore journey checks that a payer restored from its recovery phrase keeps its allowance history. +`allowance-paused-after-restore.xml` checks that a restored payer's automatic payments stay paused, +also across a relaunch, until the payer taps Resume on the Allowances tab. ## Setup diff --git a/journeys/allowances/allowance-paused-after-restore.xml b/journeys/allowances/allowance-paused-after-restore.xml new file mode 100644 index 0000000000..e7eebe9160 --- /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 (testTag "Tab-security"), then tap "Back Up Your Money" (testTag "BackupWallet"), continue past the intro (testTag "BackupIntroViewContinue"), tap to reveal the phrase (testTag "TapToReveal") and record the phrase from the word grid (testTag "SeedContainer") + Open Settings, then the "Security" tab, then "Data Backups" (testTag "BackupSettings"), and wait on the list (testTag "BackupScrollView") until the "Boosts & Transfers" row reports a backup made after the allowance became Active — that row carries no testTag, so read its text + Open Settings, then the "Security" tab, then "Reset And Restore" (testTag "ResetAndRestore"), tap "Reset Wallet" (testTag "restore_reset_button") and confirm in the dialog (testTag "restore_reset_dialog") + On the onboarding screen tap "Restore" (testTag "RestoreWallet"), enter the recorded words into the word inputs (testTag "Word-0" onwards) and tap Restore (testTag "RestoreButton") + Wait for the restore to finish and tap "Get Started" (testTag "GetStartedButton") + Open the drawer menu, tap Subscriptions and tap the Allowances tab (testTag "Tab-allowances") + Verify the paused notice (testTag "AllowancesPaused") reads "Automatic payments paused" and "This wallet was restored from a backup. Requests ask for your approval until you resume.", shows Resume (testTag "AllowancesResume"), and the payee's row (testTag "AllowanceRow-<entryId>") is listed as Active + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (testTag "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 Payment Request sheet (testTag "PaymentRequestsSheet") opens for the request, or the bell (testTag "PaymentRequestsBell") shows one pending request, and that no "Payment Executed" notification is posted, reading notifications back with `adb shell dumpsys notification --noredact` + Pay it from the sheet (testTag "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay, then verify Bitcoin Sent shows about $2.00 + Relaunch the payer with `adb shell am force-stop to.bitkit.dev` and `adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1`, open the drawer menu, tap Subscriptions and tap the Allowances tab + Verify the paused notice (testTag "AllowancesPaused") still reads "This wallet was restored from a backup. Requests ask for your approval until you resume." + Tap Resume (testTag "AllowancesResume") and, if the PIN pad shows, enter the PIN + Verify the paused notice (testTag "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, reading it back with `adb shell dumpsys notification --noredact` + + From c56662a16e1afc9fd34ebf644b7a4be2b0246cc2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 13:13:32 -0500 Subject: [PATCH 02/11] fix: keep requests under unchecked allowance limits manual Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/repositories/PaykitAllowance.kt | 14 +++- .../repositories/PaykitAllowanceExecutor.kt | 4 ++ .../PaykitAllowanceExecutorTest.kt | 17 +++++ .../repositories/PaykitAllowanceTest.kt | 70 +++++++++++++++---- .../allowance-pause-after-restore.fixed.md | 2 +- 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt index 021d4bdc19..f5242dbd11 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowance.kt @@ -42,6 +42,12 @@ data class PaykitAllowance( val lastEventAt: Instant? = null, /** The received proposal's private stream item; accepting it is bound to this item. */ val proposalStreamItemId: ULong? = null, + /** + * True when the terms carry a limit beyond the per-payment cap and the first monthly amount limit, such as a + * lifetime total, a payment count or another period. This wallet does not check those itself, so requests under + * this Allowance stay on the manual flow. + */ + val hasUncheckedLimits: Boolean = false, ) { @Serializable data class Id( @@ -115,7 +121,12 @@ data class PaykitAllowance( } val terms = record.terms ?: return null if (terms.asset() != PaykitIssuerInterop.BITCOIN_ASSET) return null - val monthly = terms.periodLimits().firstOrNull { isMonthly(it.period()) } + val periodLimits = terms.periodLimits() + val monthlyIndex = periodLimits.indexOfFirst { isMonthly(it.period()) } + val monthly = periodLimits.getOrNull(monthlyIndex) + val hasUncheckedLimits = terms.lifetimeAmountLimit() != null || + monthly?.paymentCountLimit() != null || + periodLimits.indices.any { it != monthlyIndex } val perPaymentMaxSats = terms.perPaymentAmount()?.let { satsFromBitcoinAmount(it.maximum()) } val monthlyLimitSats = monthly?.amountLimit()?.let(::satsFromBitcoinAmount) val monthlyAnchor = monthly?.period()?.anchor()?.let(PaykitAllowanceTime::parse) @@ -136,6 +147,7 @@ data class PaykitAllowance( allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers(), lastEventAt = record.lastEventAt?.let(PaykitAllowanceTime::parse), proposalStreamItemId = record.proposalStreamItemId, + hasUncheckedLimits = hasUncheckedLimits, ) } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 8676d991b5..ba13eec770 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -663,6 +663,10 @@ class PaykitAllowanceExecutor @Inject constructor( if (isAbovePerPaymentMax) notifyLimitReached(request, identity) return PaykitAllowanceAutoPayResult.MANUAL } + if (allowance.hasUncheckedLimits) { + Logger.info("Kept an incoming request manual: its allowance has unchecked limits", context = TAG) + return PaykitAllowanceAutoPayResult.MANUAL + } val ledger = paykitSdkService.allowanceAccountingState() if (!acceptsLedger(ledger, identity)) { diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 054e286118..b4a746ef67 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -541,6 +541,23 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.STARTED, resumed) } + @Test + fun `an allowance with limits this wallet does not check keeps the request manual`() = test { + collectEvents() + val request = fixtures.paymentRequest() + + val unchecked = sut.autoPay(request, listOf(fixtures.allowance(hasUncheckedLimits = true)), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, unchecked) + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any(), any()) + verifyNoInteractions(payer) + assertTrue(events.isEmpty(), "Limits this wallet does not check raise no Limit Reached") + + val bounded = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, bounded) + } + @Test fun `a ledger that goes behind this wallet during admission keeps the request manual`() = test { sut.updateLocalState(identity) { it.copy(ledgerRevisionFloor = 5uL) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt index 9d4da15690..9f5b9b15bd 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceTest.kt @@ -117,6 +117,31 @@ class PaykitAllowanceTest : BaseUnitTest() { assertTrue(received.isAnswerable) } + @Test + fun `terms with only a per-payment cap and one monthly amount limit are checked by this wallet`() { + val allowance = checkNotNull(PaykitAllowance.from(fixtures.record())) + + assertFalse(allowance.hasUncheckedLimits) + } + + @Test + fun `terms with a lifetime, count or other period limit are flagged unchecked`() { + val unchecked = listOf( + fixtures.terms(lifetimeAmountLimit = "0.001"), + fixtures.terms(monthlyPaymentCountLimit = 3uL), + fixtures.terms(otherPeriodLimits = listOf(fixtures.periodLimit("0.0001", unit = "week"))), + fixtures.terms(otherPeriodLimits = listOf(fixtures.periodLimit("0.0002"))), + fixtures.terms(otherPeriodLimits = listOf(fixtures.periodLimit(null, paymentCountLimit = 5uL))), + ) + + for (terms in unchecked) { + val allowance = checkNotNull(PaykitAllowance.from(fixtures.record(terms = terms))) + assertTrue(allowance.hasUncheckedLimits) + assertEquals(5_000uL, allowance.perPaymentMaxSats) + assertEquals(50_000uL, allowance.monthlyLimitSats) + } + } + @Test fun `record without usable role, terms or asset is skipped`() { assertNull(PaykitAllowance.from(fixtures.record(localRole = null))) @@ -577,6 +602,9 @@ internal object PaykitAllowanceFixtures { expiresAt: String? = null, asset: String = PaykitIssuerInterop.BITCOIN_ASSET, allowedPaymentEndpointIdentifiers: List? = listOf(lightningIdentifier), + lifetimeAmountLimit: String? = null, + monthlyPaymentCountLimit: ULong? = null, + otherPeriodLimits: List = emptyList(), ): AllowanceTerms { val termsAsset = asset val termsActiveFrom = activeFrom @@ -589,28 +617,42 @@ internal object PaykitAllowanceFixtures { on { maximum() } doReturn max } } - val monthlyPeriod = mock { - on { kind() } doReturn "anchored" - on { every() } doReturn 1uL - on { unit() } doReturn "month" - on { anchor() } doReturn periodAnchor - } - val periodLimit = mock { - on { amountLimit() } doReturn monthlyLimit - on { paymentCountLimit() } doReturn null - on { period() } doReturn monthlyPeriod - } + val monthly = periodLimit(monthlyLimit, anchor = periodAnchor, paymentCountLimit = monthlyPaymentCountLimit) + val lifetimeLimit = lifetimeAmountLimit return mock { on { asset() } doReturn termsAsset on { perPaymentAmount() } doReturn range - on { periodLimits() } doReturn listOf(periodLimit) - on { lifetimeAmountLimit() } doReturn null + on { periodLimits() } doReturn listOf(monthly) + otherPeriodLimits + on { lifetimeAmountLimit() } doReturn lifetimeLimit on { activeFrom() } doReturn termsActiveFrom on { expiresAt() } doReturn termsExpiresAt on { allowedPaymentEndpointIdentifiers() } doReturn allowlist } } + fun periodLimit( + amountLimit: String?, + unit: String = "month", + anchor: String? = "2026-09-01T00:00:00Z", + paymentCountLimit: ULong? = null, + ): AllowancePeriodLimit { + val limitAmount = amountLimit + val periodUnit = unit + val periodAnchor = anchor + val countLimit = paymentCountLimit + val limitPeriod = mock { + on { kind() } doReturn "anchored" + on { every() } doReturn 1uL + on { unit() } doReturn periodUnit + on { anchor() } doReturn periodAnchor + } + return mock { + on { amountLimit() } doReturn limitAmount + on { paymentCountLimit() } doReturn countLimit + on { period() } doReturn limitPeriod + } + } + @Suppress("LongParameterList") fun record( allowanceId: String = ALLOWANCE_ID, @@ -659,6 +701,7 @@ internal object PaykitAllowanceFixtures { expiresAt: Instant? = null, lastEventAt: Instant? = null, isProposedByMe: Boolean = role == PaykitAllowance.Role.ALLOWER, + hasUncheckedLimits: Boolean = false, ) = PaykitAllowance( id = PaykitAllowance.Id(counterparty, allowanceId), role = role, @@ -670,6 +713,7 @@ internal object PaykitAllowanceFixtures { expiresAt = expiresAt, allowedPaymentEndpointIdentifiers = listOf(lightningIdentifier), lastEventAt = lastEventAt, + hasUncheckedLimits = hasUncheckedLimits, ) fun capacityAttempt( diff --git a/changelog.d/next/allowance-pause-after-restore.fixed.md b/changelog.d/next/allowance-pause-after-restore.fixed.md index e55d15898c..d1c892a390 100644 --- a/changelog.d/next/allowance-pause-after-restore.fixed.md +++ b/changelog.d/next/allowance-pause-after-restore.fixed.md @@ -1 +1 @@ -Automatic allowance payments now stay paused after a wallet restore until you resume them. +Automatic allowance payments now stay paused after a wallet restore until you resume them, and requests under an allowance with limits Bitkit does not check itself ask for your approval. From 57991dd8daa88ecfeca6b6efdbe72b6a58b081c1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 14:07:25 -0500 Subject: [PATCH 03/11] fix: keep the restore pause in its own keychain entry Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/data/keychain/Keychain.kt | 1 + .../repositories/PaykitAllowanceExecutor.kt | 4 +- .../repositories/PaykitAllowanceStore.kt | 33 ++++++------- .../PaykitAllowanceExecutorTest.kt | 11 ++++- .../repositories/PaykitAllowanceStoreTest.kt | 48 +++++++++++++++---- 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 9de587155e..cf7d522736 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -250,6 +250,7 @@ class Keychain @Inject constructor( PAYKIT_USDT_PAYMENTS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, PAYKIT_ALLOWANCE_STATE, + PAYKIT_ALLOWANCE_RESTORE_PAUSE, PUBKY_SECRET_KEY, SHARED_PUBKY_SOURCE, } diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index ba13eec770..34564874d1 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -328,9 +328,7 @@ class PaykitAllowanceExecutor @Inject constructor( fun isPausedAfterRestore(): Boolean = store.isPausedAfterRestore() /** Lets automatic payments run again after a wallet restore. The restored spends still count toward the limits. */ - suspend fun resumeAfterRestore(): Unit = withContext(ioDispatcher) { - stateMutex.withLock { store.resumeAfterRestore() } - } + suspend fun resumeAfterRestore(): Unit = withContext(ioDispatcher) { store.resumeAfterRestore() } // endregion diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt index 03097db4a8..7c1b4d71b9 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceStore.kt @@ -98,14 +98,13 @@ class PaykitAllowanceStore @Inject constructor( companion object { private const val TAG = "PaykitAllowanceStore" private val KEY = Keychain.Key.PAYKIT_ALLOWANCE_STATE.name + private val RESTORE_PAUSE_KEY = Keychain.Key.PAYKIT_ALLOWANCE_RESTORE_PAUSE.name private val storeJson = Json { ignoreUnknownKeys = true } } @Serializable private data class Stored( val statesByIdentity: Map = emptyMap(), - /** Set by a wallet restore and kept out of the backup; cleared when the user resumes automatic payments. */ - val isPausedAfterRestore: Boolean = false, ) private val _backupStateVersion = MutableStateFlow(0L) @@ -151,25 +150,23 @@ class PaykitAllowanceStore @Inject constructor( _backupStateVersion.update { it + 1 } } - /** True from a wallet restore until the user resumes automatic payments, also across app restarts. */ + /** + * True from a wallet restore until the user resumes automatic payments, also across app restarts. A pause that + * cannot be read counts as set. + */ fun isPausedAfterRestore(): Boolean = - runCatching { loadAll().isPausedAfterRestore } - .onFailure { Logger.warn("Failed to load Paykit allowance state", it, context = TAG) } - .getOrDefault(false) + runCatching { keychain.loadString(RESTORE_PAUSE_KEY) != null } + .onFailure { Logger.warn("Failed to load the Paykit allowance restore pause", it, context = TAG) } + .getOrDefault(true) - /** Pauses automatic payments of every identity after a wallet restore, until [resumeAfterRestore]. */ - suspend fun pauseAfterRestore() { - // A restore may replace state this build cannot read, as restoreBackup does. - val stored = runCatching { loadAll() }.getOrDefault(Stored()) - keychain.upsertString(KEY, storeJson.encodeToString(stored.copy(isPausedAfterRestore = true))) - } + /** + * Pauses automatic payments of every identity after a wallet restore, until [resumeAfterRestore]. The pause has its + * own keychain entry, apart from the backed-up allowance state, and the wallet wipe clears it with the keychain. + */ + suspend fun pauseAfterRestore() = keychain.upsertString(RESTORE_PAUSE_KEY, true.toString()) - /** Clears the pause [pauseAfterRestore] set. Throws on state this build cannot read, which it never replaces. */ - suspend fun resumeAfterRestore() { - val stored = loadAll() - if (!stored.isPausedAfterRestore) return - keychain.upsertString(KEY, storeJson.encodeToString(stored.copy(isPausedAfterRestore = false))) - } + /** Clears the pause [pauseAfterRestore] set. */ + suspend fun resumeAfterRestore() = keychain.delete(RESTORE_PAUSE_KEY) private fun PaykitAllowanceLocalState.withoutWatermark() = copy(lastTrustedTimeMillis = null) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index b4a746ef67..ea994dd37a 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -79,6 +79,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { private const val ONCHAIN_ADDRESS = "bcrt1qallowancetestaddress" private const val PAYMENT_APP_ID = "bitkit" private val PAYMENT_HASH = "ab".repeat(32) + private val STATE_KEY = Keychain.Key.PAYKIT_ALLOWANCE_STATE.name } private val fixtures = PaykitAllowanceFixtures @@ -87,6 +88,7 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { private val payer = mock() private val keychain = mock() private var storedState: String? = null + private val otherKeychainValues = mutableMapOf() private var failSave: (String) -> Boolean = { false } private var now = fixtures.now private val clock = object : Clock { @@ -127,8 +129,15 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { } private suspend fun stubLedger() { - whenever(keychain.loadString(any())).thenAnswer { storedState } + whenever(keychain.loadString(any())).thenAnswer { + if (it.getArgument(0) == STATE_KEY) storedState else otherKeychainValues[it.getArgument(0)] + } + whenever(keychain.delete(any())).doSuspendableAnswer { otherKeychainValues.remove(it.getArgument(0)).let {} } whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { + if (it.getArgument(0) != STATE_KEY) { + otherKeychainValues[it.getArgument(0)] = it.getArgument(1) + return@doSuspendableAnswer Unit + } val state = it.getArgument(1) if (failSave(state)) throw TestAllowanceError("save") storedState = state diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt index b233ab50ab..1cd3ab88dc 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt @@ -8,6 +8,7 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.KeychainError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals @@ -18,13 +19,23 @@ import kotlin.test.assertTrue class PaykitAllowanceStoreTest : BaseUnitTest() { private val identity = PaykitAllowanceFixtures.identityKey private val keychain = mock() - private var stored: String? = null + private val stateKey = Keychain.Key.PAYKIT_ALLOWANCE_STATE.name + private val pauseKey = Keychain.Key.PAYKIT_ALLOWANCE_RESTORE_PAUSE.name + private val values = mutableMapOf() + private var stored: String? + get() = values[stateKey] + set(value) { + if (value == null) values.remove(stateKey) else values[stateKey] = value + } private lateinit var sut: PaykitAllowanceStore @Before fun setUp() = test { - whenever(keychain.loadString(any())).thenAnswer { stored } - whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { stored = it.getArgument(1) } + whenever(keychain.loadString(any())).thenAnswer { values[it.getArgument(0)] } + whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { + values[it.getArgument(0)] = it.getArgument(1) + } + whenever(keychain.delete(any())).doSuspendableAnswer { values.remove(it.getArgument(0)).let {} } sut = PaykitAllowanceStore(keychain) } @@ -89,29 +100,48 @@ class PaykitAllowanceStoreTest : BaseUnitTest() { } @Test - fun `restore pause stays out of the backup and outlives restored state`() = test { + fun `restore pause is kept apart from the backed-up allowance state`() = test { val restored = PaykitAllowanceLocalState(autoPaidSatsByAllowance = mapOf("key" to 1_000uL)) - sut.pauseAfterRestore() + sut.save(identity, PaykitAllowanceLocalState(notifiedRequestIds = setOf("request-1"))) + val state = stored + val snapshot = sut.backupSnapshot() val before = sut.backupStateVersion.value + sut.pauseAfterRestore() + + assertEquals(state, stored, "The pause never rewrites the allowance state") + assertEquals(snapshot, sut.backupSnapshot()) + assertEquals(before, sut.backupStateVersion.value) + assertTrue(pauseKey in values) + sut.restoreBackup(mapOf(identity to restored)) - sut.save(identity, restored.copy(notifiedRequestIds = setOf("request-1"))) assertTrue(sut.isPausedAfterRestore()) - assertEquals(before + 2, sut.backupStateVersion.value) assertEquals(setOf(PubkyPublicKeyFormat.normalized(identity) ?: identity), sut.backupSnapshot().keys) } @Test - fun `resuming never replaces state this build cannot read`() = test { + fun `restore pause never rewrites state this build cannot read`() = test { stored = "{\"statesByIdentity\":{\"other\":{\"journal\":[{\"stage\":\"from-a-newer-build\"}]}}}" val unreadable = stored + sut.pauseAfterRestore() + assertTrue(sut.isPausedAfterRestore()) + sut.resumeAfterRestore() + assertFalse(sut.isPausedAfterRestore()) - assertFailsWith { sut.resumeAfterRestore() } assertEquals(unreadable, stored) } + @Test + fun `a restore pause that cannot be read counts as paused`() = test { + assertFalse(sut.isPausedAfterRestore()) + + whenever(keychain.loadString(pauseKey)).thenAnswer { throw KeychainError.FailedToLoad(pauseKey) } + + assertTrue(sut.isPausedAfterRestore()) + } + @Test fun `state saved before the automatic payment checks still loads`() = test { val key = PubkyPublicKeyFormat.normalized(identity) ?: identity From 8695184f3bbbafdca4a7c1314450ec2deacb8003 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 14:09:42 -0500 Subject: [PATCH 04/11] fix: pause automatic payments before restoring wallet state Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/to/bitkit/repositories/BackupRepo.kt | 4 ++-- app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt | 7 ++++--- journeys/allowances/README.md | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 7a315cf9d1..4804c1dd63 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -1116,6 +1116,8 @@ class BackupRepo @Inject constructor( } private suspend fun restoreWalletBackup(dataBytes: ByteArray): Long { + // Automatic allowance payments wait for the user to resume them before any restored state is written. + paykitAllowanceStore.pauseAfterRestore() keychain.upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, String(dataBytes)) val parsed = json.decodeFromString(String(dataBytes)) var restoredAttempt: OnchainSendAttempt? = null @@ -1153,8 +1155,6 @@ class BackupRepo @Inject constructor( paykitPresentationStore.restoreAcceptedOneTimeRequests(it.acceptedOneTimeRequests.orEmpty()) paykitAllowanceStore.restoreBackup(allowances) } - // Automatic allowance payments wait for the user to resume them; restored spending still counts. - paykitAllowanceStore.pauseAfterRestore() db.transferDao().upsert(parsed.transfers) watchOnlyAccountRepo.restore( parsed.watchOnlyAccounts.orEmpty(), diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 68a097a432..ee2f953ee2 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -716,7 +716,7 @@ class BackupRepoTest : BaseUnitTest() { } @Test - fun `wallet restore pauses automatic allowance payments after restoring their state`() = test { + fun `wallet restore pauses automatic allowance payments before restoring wallet state`() = test { val identity = "pubky" + "z".repeat(52) val allowances = PaykitAllowanceStateBackup(ledgerRevisionFloor = 5uL) stubWalletBackup( @@ -730,9 +730,10 @@ class BackupRepoTest : BaseUnitTest() { sut.performFullRestoreFromLatestBackup().getOrThrow() - val order = inOrder(paykitAllowanceStore) - order.verify(paykitAllowanceStore).restoreBackup(mapOf(identity to allowances.restored())) + val order = inOrder(paykitAllowanceStore, privatePaykitRepo) order.verify(paykitAllowanceStore).pauseAfterRestore() + order.verify(paykitAllowanceStore).restoreBackup(mapOf(identity to allowances.restored())) + order.verify(privatePaykitRepo).restoreBackup("saved-sdk-state") } @Test diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 76c85caf63..0a73d96888 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -8,6 +8,8 @@ The restore journey checks that a payer restored from its recovery phrase keeps `allowance-paused-after-restore.xml` checks that a restored payer's automatic payments stay paused, also across a relaunch, until the payer taps Resume on the Allowances tab. +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. + ## Setup Allowances are on by default and can be turned off from Dev Settings ("Enable Allowances"). With them From 482aa97c0691752a1403a33e83d2398081b22a23 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 14:13:07 -0500 Subject: [PATCH 05/11] fix: check the ledger before resuming after a restore Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitAllowanceExecutor.kt | 10 ++++-- .../repositories/PaykitAllowanceRepo.kt | 17 +++++++-- .../PaykitAllowanceExecutorTest.kt | 19 ++++++++++ .../repositories/PaykitAllowanceRepoTest.kt | 36 +++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 34564874d1..516621c9ec 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -309,10 +309,14 @@ class PaykitAllowanceExecutor @Inject constructor( /** True while the ledger read is older than one this wallet has read, which keeps automatic payments paused. */ suspend fun isLedgerBehind(identity: String): Boolean = withContext(ioDispatcher) { + runSuspendCatching { isLedgerBehindOrThrow(identity) }.getOrDefault(false) + } + + /** Like [isLedgerBehind], but a ledger that cannot be read throws instead of counting as current. */ + suspend fun isLedgerBehindOrThrow(identity: String): Boolean = withContext(ioDispatcher) { val floor = localState(identity).ledgerRevisionFloor ?: return@withContext false - runSuspendCatching { paykitSdkService.allowanceAccountingState() } - .map { ledger -> ledger == null || ledger.revision < floor } - .getOrDefault(false) + val ledger = paykitSdkService.allowanceAccountingState() + ledger == null || ledger.revision < floor } /** diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt index 75299b1d44..04f907b503 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceRepo.kt @@ -150,8 +150,10 @@ class PaykitAllowanceRepo @Inject constructor( private val _automaticPaymentPause = MutableStateFlow(null) /** - * Why automatic payments are paused until [resumeAutomaticPayments], or null while they run. A ledger behind this - * wallet comes first; a restore pauses them only while this wallet pays through an allowance. + * Why automatic payments are paused until [resumeAutomaticPayments], or null while no pause notice shows. A ledger + * behind this wallet comes first. A wallet restore pauses automatic payments on this device until Resume, but its + * notice shows only while the wallet pays through a proposed or accepted allowance, so an allowance created later + * on a restored wallet starts paused. */ val automaticPaymentPause: StateFlow = _automaticPaymentPause.asStateFlow() @@ -459,11 +461,20 @@ class PaykitAllowanceRepo @Inject constructor( /** * Resumes automatic payments from the ledger read now and ends the pause after a wallet restore; see - * [PaykitAllowanceExecutor.acceptCurrentLedger]. + * [PaykitAllowanceExecutor.acceptCurrentLedger]. Resuming from the restore notice first checks the ledger: one + * behind this wallet stays paused and shows its own notice, and one that cannot be read fails, so neither is + * accepted without the user seeing that notice. */ suspend fun resumeAutomaticPayments(): Result = withContext(ioDispatcher) { runSuspendCatching { val identity = activeIdentity ?: throw PaykitAllowanceError.Unavailable + if ( + _automaticPaymentPause.value == PaykitAllowancePause.RESTORED && + executor.isLedgerBehindOrThrow(identity) + ) { + refresh() + return@runSuspendCatching + } executor.acceptCurrentLedger(identity) executor.resumeAfterRestore() executor.recover(identity) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index ea994dd37a..f0b60c6f46 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -49,6 +49,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PaykitAllowanceStateBackup import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset @@ -567,6 +568,24 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(PaykitAllowanceAutoPayResult.STARTED, bounded) } + @Test + fun `only the strict ledger check fails when the ledger cannot be read`() = test { + sut.updateLocalState(identity) { it.copy(ledgerRevisionFloor = 5uL) } + var failLedgerRead = true + whenever(sdk.allowanceAccountingState()).doSuspendableAnswer { + if (failLedgerRead) throw TestAllowanceError("ledger") + accountingState + } + + assertFalse(sut.isLedgerBehind(identity)) + assertIs(runSuspendCatching { sut.isLedgerBehindOrThrow(identity) }.exceptionOrNull()) + + failLedgerRead = false + accountingState = fixtures.accountingState(revision = 4uL) + + assertTrue(sut.isLedgerBehindOrThrow(identity)) + } + @Test fun `a ledger that goes behind this wallet during admission keeps the request manual`() = test { sut.updateLocalState(identity) { it.copy(ledgerRevisionFloor = 5uL) } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt index f4c0677612..5a84de4c70 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceRepoTest.kt @@ -78,6 +78,7 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { whenever(executor.events).thenReturn(executorEvents) whenever(executor.localState(any())).thenAnswer { localState } whenever(executor.isLedgerBehind(any())).thenReturn(false) + whenever(executor.isLedgerBehindOrThrow(any())).thenReturn(false) whenever(executor.rememberEnding(any(), any(), any())).doSuspendableAnswer { it.getArgument Unit>(2).invoke() } @@ -216,6 +217,41 @@ class PaykitAllowanceRepoTest : BaseUnitTest() { assertNull(sut.automaticPaymentPause.value) } + @Test + fun `resuming from the restore notice keeps a ledger behind this wallet paused with its own notice`() = test { + var isLedgerBehind = false + whenever(executor.isPausedAfterRestore()).thenReturn(true) + whenever(executor.isLedgerBehind(identity)).thenAnswer { isLedgerBehind } + whenever(executor.isLedgerBehindOrThrow(identity)).thenAnswer { isLedgerBehind } + records = listOf(fixtures.record()) + sut.activate(identity) + assertEquals(PaykitAllowancePause.RESTORED, sut.automaticPaymentPause.value) + isLedgerBehind = true + + sut.resumeAutomaticPayments().getOrThrow() + + verify(executor, never()).acceptCurrentLedger(any()) + verify(executor, never()).resumeAfterRestore() + assertEquals(PaykitAllowancePause.LEDGER_BEHIND, sut.automaticPaymentPause.value) + } + + @Test + fun `resuming from the restore notice fails without accepting a ledger that cannot be read`() = test { + whenever(executor.isPausedAfterRestore()).thenReturn(true) + whenever(executor.isLedgerBehindOrThrow(identity)).doSuspendableAnswer { + throw PaykitAllowanceError.Unavailable + } + records = listOf(fixtures.record()) + sut.activate(identity) + + val result = sut.resumeAutomaticPayments() + + assertEquals(PaykitAllowanceError.Unavailable, result.exceptionOrNull()) + verify(executor, never()).acceptCurrentLedger(any()) + verify(executor, never()).resumeAfterRestore() + assertEquals(PaykitAllowancePause.RESTORED, sut.automaticPaymentPause.value) + } + @Test fun `auto-paid sats and requests come from the lasting totals, not the trimmed journal`() = test { records = listOf(fixtures.record(allowanceId = ALLOWANCE_ID)) From 575bea23ed2a3b9ef5a87f517767608252264d5b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 14:15:00 -0500 Subject: [PATCH 06/11] fix: hand back requests under unchecked limits while offline Co-Authored-By: Claude Opus 5.5 --- .../bitkit/repositories/PaykitAllowanceExecutor.kt | 6 +++++- .../repositories/PaykitAllowanceExecutorTest.kt | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index 516621c9ec..c4c5e4ff20 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -441,7 +441,9 @@ class PaykitAllowanceExecutor @Inject constructor( /** * True when [request] fits no active Allowance of its contact, checked locally while the node cannot pay yet; - * such a request asks at once instead of waiting for channels, and raises Limit Reached. + * such a request asks at once instead of waiting for channels, and raises Limit Reached. Also true, without Limit + * Reached, while automatic payments are paused or when every matching Allowance has limits this wallet does not + * check. */ suspend fun classifyAboveLimit( request: PaykitPaymentRequest, @@ -455,6 +457,8 @@ class PaykitAllowanceExecutor @Inject constructor( PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } if (matching.isEmpty()) return@withContext false + // Requests under limits this wallet does not check never pay automatically, so they ask without waiting. + if (matching.all { it.hasUncheckedLimits }) return@withContext true // Paused automatic payments keep the request manual, without waiting for the node. if (isPausedAfterRestore()) return@withContext true val read = runSuspendCatching { paykitSdkService.allowanceAccountingState() } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index f0b60c6f46..604cc75fce 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -1475,6 +1475,19 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { verify(sdk, never()).allowanceAccountingState() } + @Test + fun `local capacity check hands back a request under limits this wallet does not check`() = test { + collectEvents() + val unchecked = listOf(fixtures.allowance(hasUncheckedLimits = true)) + + val isManual = sut.classifyAboveLimit(fixtures.paymentRequest(), unchecked, identity) + + assertTrue(isManual) + assertTrue(events.isEmpty(), "Limits this wallet does not check raise no Limit Reached") + val mixed = unchecked + fixtures.allowance(allowanceId = PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID) + assertFalse(sut.classifyAboveLimit(fixtures.paymentRequest(), mixed, identity)) + } + @Test fun `local capacity check flags only requests that fit no allowance`() = test { collectEvents() From 52d99e108764031a070a541144b04914f23874e7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 14:15:17 -0500 Subject: [PATCH 07/11] test: wrap the allowance keychain delete stubs Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/repositories/PaykitAllowanceExecutorTest.kt | 5 ++++- .../java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 604cc75fce..eea1eefe37 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -133,7 +133,10 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { whenever(keychain.loadString(any())).thenAnswer { if (it.getArgument(0) == STATE_KEY) storedState else otherKeychainValues[it.getArgument(0)] } - whenever(keychain.delete(any())).doSuspendableAnswer { otherKeychainValues.remove(it.getArgument(0)).let {} } + whenever(keychain.delete(any())).doSuspendableAnswer { + otherKeychainValues.remove(it.getArgument(0)) + Unit + } whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { if (it.getArgument(0) != STATE_KEY) { otherKeychainValues[it.getArgument(0)] = it.getArgument(1) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt index 1cd3ab88dc..0287fbf6f8 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceStoreTest.kt @@ -35,7 +35,10 @@ class PaykitAllowanceStoreTest : BaseUnitTest() { whenever(keychain.upsertString(any(), any())).doSuspendableAnswer { values[it.getArgument(0)] = it.getArgument(1) } - whenever(keychain.delete(any())).doSuspendableAnswer { values.remove(it.getArgument(0)).let {} } + whenever(keychain.delete(any())).doSuspendableAnswer { + values.remove(it.getArgument(0)) + Unit + } sut = PaykitAllowanceStore(keychain) } From 2301247ff2502efdb0e29443070b6c8da63fe184 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 15:17:59 -0500 Subject: [PATCH 08/11] fix: pay automatically only through allowances Bitkit can check Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitAllowanceExecutor.kt | 114 ++++++++++-------- .../PaykitAllowanceExecutorTest.kt | 94 ++++++++++++++- 2 files changed, 156 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index c4c5e4ff20..e93b4726bd 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -440,10 +440,10 @@ class PaykitAllowanceExecutor @Inject constructor( } /** - * True when [request] fits no active Allowance of its contact, checked locally while the node cannot pay yet; - * such a request asks at once instead of waiting for channels, and raises Limit Reached. Also true, without Limit - * Reached, while automatic payments are paused or when every matching Allowance has limits this wallet does not - * check. + * True when [request] cannot pay automatically, checked locally while the node cannot pay yet; such a request asks + * at once instead of waiting for channels. It waits only while an Allowance without unchecked limits fits it, see + * [fittingAllowances]. Limit Reached is raised only when no matching Allowance fits at all, never while automatic + * payments are paused or the ledger is behind this wallet. */ suspend fun classifyAboveLimit( request: PaykitPaymentRequest, @@ -452,13 +452,9 @@ class PaykitAllowanceExecutor @Inject constructor( ): Boolean = withContext(ioDispatcher) { val amountSats = request.allowanceSats() ?: return@withContext false val now = clock.now() - val matching = allowances.filter { - it.isAllower && it.status(now) == PaykitAllowance.Status.ACTIVE && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) - } + val local = localState(identity) + val matching = matchingAllowances(request, allowances, local, now) if (matching.isEmpty()) return@withContext false - // Requests under limits this wallet does not check never pay automatically, so they ask without waiting. - if (matching.all { it.hasUncheckedLimits }) return@withContext true // Paused automatic payments keep the request manual, without waiting for the node. if (isPausedAfterRestore()) return@withContext true val read = runSuspendCatching { paykitSdkService.allowanceAccountingState() } @@ -466,15 +462,42 @@ class PaykitAllowanceExecutor @Inject constructor( if (read.isSuccess && runSuspendCatching { !acceptsLedger(read.getOrNull(), identity) }.getOrDefault(true)) { return@withContext true } - val attempts = read.getOrNull()?.let { PaykitAllowanceCapacity.attempts(it.history) }.orEmpty() - val localAttempts = PaykitAllowanceCapacity.attempts(localState(identity).automaticSpends) - if (matching.any { PaykitAllowanceCapacity.fits(amountSats, it, attempts, now, localAttempts) }) { - return@withContext false - } - notifyLimitReached(request, identity) + val fitting = fittingAllowances(amountSats, matching, read.getOrNull(), local, now) + if (fitting.any { !it.hasUncheckedLimits }) return@withContext false + if (fitting.isEmpty()) notifyLimitReached(request, identity) true } + /** The active Allowances this wallet grants [request]'s contact, without the ones it has seen ended. */ + private fun matchingAllowances( + request: PaykitPaymentRequest, + allowances: List, + local: PaykitAllowanceLocalState, + now: Instant, + ): List = allowances.filter { + it.isAllower && it.status(now) == PaykitAllowance.Status.ACTIVE && + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && + it.key !in local.endedAllowanceIds + } + + /** + * The [matching] Allowances that fit [amountSats] under the limits this wallet checks itself: the per-payment cap + * and the monthly amount, counting the ledger's and this wallet's own spending. A request pays automatically only + * through a fitting Allowance without unchecked limits; one that fits only Allowances with unchecked limits asks + * without Limit Reached, and Limit Reached is for a request that fits none. + */ + private fun fittingAllowances( + amountSats: ULong, + matching: List, + ledger: AllowanceAccountingState?, + local: PaykitAllowanceLocalState, + now: Instant, + ): List { + val attempts = ledger?.let { PaykitAllowanceCapacity.attempts(it.history) }.orEmpty() + val localAttempts = PaykitAllowanceCapacity.attempts(local.automaticSpends) + return matching.filter { PaykitAllowanceCapacity.fits(amountSats, it, attempts, now, localAttempts) } + } + /** Settles journaled Lightning attempts whose outcome the node already knows, for events missed while inactive. */ suspend fun settleOpenLightningAttempts(): Unit = withContext(ioDispatcher) { val identity = activeIdentity ?: return@withContext @@ -642,35 +665,10 @@ class PaykitAllowanceExecutor @Inject constructor( val selectionTime = trustedTime(identity) val candidates = paykitSdkService.evaluateAllowanceCandidates(scope, selectionTime) val local = localState(identity) - val unblocked = candidates.filter { it.blocked == null } - val candidate = unblocked.firstOrNull { - PaykitAllowance.key(request.counterparty, it.allowanceId) !in local.endedAllowanceIds - } - if (candidate == null && unblocked.isNotEmpty()) { - Logger.info("Kept an incoming request manual: its allowance has ended", context = TAG) - return PaykitAllowanceAutoPayResult.MANUAL - } - val allowance = candidate?.let { eligible -> - allowances.firstOrNull { - it.allowanceId == eligible.allowanceId && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) - } - } - if (candidate == null || allowance == null) { - val reasons = candidates.mapNotNull { it.blocked } - Logger.info("Kept an incoming request manual: no eligible allowance, blocked by '$reasons'", context = TAG) - // The SDK blocks a request above the per-payment limit before the capacity check below can notify. - val now = clock.now() - val isAbovePerPaymentMax = allowances.any { granted -> - granted.isAllower && granted.status(now) == PaykitAllowance.Status.ACTIVE && - PubkyPublicKeyFormat.matches(granted.counterparty, request.counterparty) && - granted.perPaymentMaxSats?.let { amountSats > it } == true - } - if (isAbovePerPaymentMax) notifyLimitReached(request, identity) - return PaykitAllowanceAutoPayResult.MANUAL - } - if (allowance.hasUncheckedLimits) { - Logger.info("Kept an incoming request manual: its allowance has unchecked limits", context = TAG) + val now = clock.now() + val matching = matchingAllowances(request, allowances, local, now) + if (matching.isEmpty()) { + Logger.info("Kept an incoming request manual: no active allowance, or it has ended", context = TAG) return PaykitAllowanceAutoPayResult.MANUAL } @@ -679,11 +677,27 @@ class PaykitAllowanceExecutor @Inject constructor( Logger.info("Kept an incoming request manual: the ledger is behind this wallet", context = TAG) return PaykitAllowanceAutoPayResult.MANUAL } - val attempts = ledger?.let { PaykitAllowanceCapacity.attempts(it.history) }.orEmpty() - val localAttempts = PaykitAllowanceCapacity.attempts(local.automaticSpends) - if (!PaykitAllowanceCapacity.fits(amountSats, allowance, attempts, clock.now(), localAttempts)) { - Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG) - notifyLimitReached(request, identity) + val fitting = fittingAllowances(amountSats, matching, ledger, local, now) + val checked = fitting.filterNot { it.hasUncheckedLimits } + val candidate = candidates.firstOrNull { eligible -> + eligible.blocked == null && checked.any { it.allowanceId == eligible.allowanceId } + } + if (candidate == null) { + val reasons = candidates.mapNotNull { it.blocked } + when { + fitting.isEmpty() -> { + Logger.info("Kept an incoming request manual: the allowance limit is reached", context = TAG) + notifyLimitReached(request, identity) + } + checked.isEmpty() -> Logger.info( + "Kept an incoming request manual: it fits only allowances with unchecked limits", + context = TAG, + ) + else -> Logger.info( + "Kept an incoming request manual: no eligible allowance, blocked by '$reasons'", + context = TAG, + ) + } return PaykitAllowanceAutoPayResult.MANUAL } diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index eea1eefe37..61a1e4c3c3 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -55,6 +55,7 @@ import to.bitkit.models.PaykitAmount import to.bitkit.models.PaykitAsset import to.bitkit.models.PaykitRequestPricing import to.bitkit.repositories.PaykitAllowanceFixtures.ALLOWANCE_ID +import to.bitkit.repositories.PaykitAllowanceFixtures.SECOND_ALLOWANCE_ID import to.bitkit.repositories.PaykitAllowanceLocalState.JournalEntry import to.bitkit.repositories.PaykitAllowanceLocalState.Stage import to.bitkit.services.PaykitSdkService @@ -1491,6 +1492,82 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertFalse(sut.classifyAboveLimit(fixtures.paymentRequest(), mixed, identity)) } + @Test + fun `local capacity check hands back a request only an allowance with unchecked limits fits`() = test { + collectEvents() + val request = fixtures.paymentRequest(amountSats = 20_000uL) + + val isManual = sut.classifyAboveLimit(request, mixedAllowances(), identity) + + assertTrue(isManual) + assertTrue(events.isEmpty(), "A request an allowance still fits raises no Limit Reached") + } + + @Test + fun `a request only an allowance with unchecked limits fits stays manual without Limit Reached`() = test { + collectEvents() + val request = fixtures.paymentRequest(amountSats = 20_000uL) + val aboveCheckedCap = candidate(AllowanceAccountingBlock.SharedRule("amount_outside_range")) + val candidateSets = listOf( + listOf(aboveCheckedCap, candidate(allowanceId = SECOND_ALLOWANCE_ID)), + listOf( + aboveCheckedCap, + candidate(AllowanceAccountingBlock.SharedRule("lifetime_limit"), allowanceId = SECOND_ALLOWANCE_ID), + ), + ) + + for (sdkCandidates in candidateSets) { + candidates = sdkCandidates + + val result = sut.autoPay(request, mixedAllowances(), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + } + assertTrue(events.isEmpty(), "A request an allowance still fits raises no Limit Reached") + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any(), any()) + verifyNoInteractions(payer) + } + + @Test + fun `a request a checked allowance fits pays through it even when an unchecked one also matches`() = test { + candidates = listOf(candidate(allowanceId = SECOND_ALLOWANCE_ID), candidate()) + + val result = sut.autoPay(fixtures.paymentRequest(amountSats = 1_000uL), mixedAllowances(), identity) + + assertEquals(PaykitAllowanceAutoPayResult.STARTED, result) + assertEquals(listOf(ALLOWANCE_ID), selections.map { it.allowanceId }) + } + + @Test + fun `a request no matching allowance fits raises Limit Reached`() = test { + collectEvents() + val request = fixtures.paymentRequest(amountSats = 20_000uL) + val spend = PaykitAllowanceLocalState.AutomaticSpend( + attemptId = "local-unchecked", + counterparty = fixtures.counterpartyKey, + allowanceId = SECOND_ALLOWANCE_ID, + amountSats = 40_000uL, + admittedAtMillis = fixtures.now.toEpochMilliseconds(), + ) + sut.updateLocalState(identity) { it.copy(automaticSpends = listOf(spend)) } + candidates = listOf( + candidate(AllowanceAccountingBlock.SharedRule("amount_outside_range")), + candidate(allowanceId = SECOND_ALLOWANCE_ID), + ) + val limitReached: PaykitAllowanceEvent = PaykitAllowanceEvent.LimitReached(fixtures.counterpartyKey, 20_000uL) + + val result = sut.autoPay(request, mixedAllowances(), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertEquals(listOf(limitReached), events) + verifyNoInteractions(payer) + + val isManual = sut.classifyAboveLimit(request, mixedAllowances(), identity) + + assertTrue(isManual) + assertEquals(listOf(limitReached), events, "Limit Reached is raised once per request") + } + @Test fun `local capacity check flags only requests that fit no allowance`() = test { collectEvents() @@ -1869,12 +1946,25 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { SubscriptionClockOffset.setOffsetDays(0) } + private fun mixedAllowances() = listOf( + fixtures.allowance(perPaymentMaxSats = 5_000uL, monthlyLimitSats = 50_000uL), + fixtures.allowance( + allowanceId = SECOND_ALLOWANCE_ID, + perPaymentMaxSats = 50_000uL, + monthlyLimitSats = 50_000uL, + hasUncheckedLimits = true, + ), + ) + private fun TestScope.collectEvents() { backgroundScope.launch { sut.events.collect { events += it } } } - private fun candidate(blocked: AllowanceAccountingBlock? = null) = AllowanceCandidate( - allowanceId = ALLOWANCE_ID, + private fun candidate( + blocked: AllowanceAccountingBlock? = null, + allowanceId: String = ALLOWANCE_ID, + ) = AllowanceCandidate( + allowanceId = allowanceId, eligiblePaymentEndpointIdentifiers = listOf(PaykitAllowanceFixtures.lightningIdentifier), blocked = blocked, ) From 357faa866ed89eddf49296dc8051c94fd31e1d3a Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 16:28:28 -0500 Subject: [PATCH 09/11] fix: hand back requests whose allowances have all ended Co-Authored-By: Claude Opus 5.5 --- .../repositories/PaykitAllowanceExecutor.kt | 29 +++++------- .../PaykitAllowanceExecutorTest.kt | 47 ++++++++++++++++++- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt index e93b4726bd..db0ca587e6 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitAllowanceExecutor.kt @@ -441,9 +441,10 @@ class PaykitAllowanceExecutor @Inject constructor( /** * True when [request] cannot pay automatically, checked locally while the node cannot pay yet; such a request asks - * at once instead of waiting for channels. It waits only while an Allowance without unchecked limits fits it, see - * [fittingAllowances]. Limit Reached is raised only when no matching Allowance fits at all, never while automatic - * payments are paused or the ledger is behind this wallet. + * at once instead of waiting for channels. It waits only while an Allowance it may pay through fits it: one this + * wallet has not seen ended, without unchecked limits, within the per-payment cap and the monthly amount. Limit + * Reached is raised only when no Allowance this wallet has not seen ended fits at all, never while every covering + * Allowance has ended, automatic payments are paused or the ledger is behind this wallet. */ suspend fun classifyAboveLimit( request: PaykitPaymentRequest, @@ -453,8 +454,11 @@ class PaykitAllowanceExecutor @Inject constructor( val amountSats = request.allowanceSats() ?: return@withContext false val now = clock.now() val local = localState(identity) - val matching = matchingAllowances(request, allowances, local, now) - if (matching.isEmpty()) return@withContext false + val covering = activeAllowances(request, allowances, now) + if (covering.isEmpty()) return@withContext false + val matching = covering.filterNot { it.key in local.endedAllowanceIds } + // Allowances seen ended never pay again, so the request asks without waiting and without Limit Reached. + if (matching.isEmpty()) return@withContext true // Paused automatic payments keep the request manual, without waiting for the node. if (isPausedAfterRestore()) return@withContext true val read = runSuspendCatching { paykitSdkService.allowanceAccountingState() } @@ -468,24 +472,15 @@ class PaykitAllowanceExecutor @Inject constructor( true } - /** The active Allowances this wallet grants [request]'s contact, without the ones it has seen ended. */ - private fun matchingAllowances( + private fun activeAllowances( request: PaykitPaymentRequest, allowances: List, - local: PaykitAllowanceLocalState, now: Instant, ): List = allowances.filter { it.isAllower && it.status(now) == PaykitAllowance.Status.ACTIVE && - PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) && - it.key !in local.endedAllowanceIds + PubkyPublicKeyFormat.matches(it.counterparty, request.counterparty) } - /** - * The [matching] Allowances that fit [amountSats] under the limits this wallet checks itself: the per-payment cap - * and the monthly amount, counting the ledger's and this wallet's own spending. A request pays automatically only - * through a fitting Allowance without unchecked limits; one that fits only Allowances with unchecked limits asks - * without Limit Reached, and Limit Reached is for a request that fits none. - */ private fun fittingAllowances( amountSats: ULong, matching: List, @@ -666,7 +661,7 @@ class PaykitAllowanceExecutor @Inject constructor( val candidates = paykitSdkService.evaluateAllowanceCandidates(scope, selectionTime) val local = localState(identity) val now = clock.now() - val matching = matchingAllowances(request, allowances, local, now) + val matching = activeAllowances(request, allowances, now).filterNot { it.key in local.endedAllowanceIds } if (matching.isEmpty()) { Logger.info("Kept an incoming request manual: no active allowance, or it has ended", context = TAG) return PaykitAllowanceAutoPayResult.MANUAL diff --git a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt index 61a1e4c3c3..03126ce52c 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitAllowanceExecutorTest.kt @@ -1512,7 +1512,10 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { listOf(aboveCheckedCap, candidate(allowanceId = SECOND_ALLOWANCE_ID)), listOf( aboveCheckedCap, - candidate(AllowanceAccountingBlock.SharedRule("lifetime_limit"), allowanceId = SECOND_ALLOWANCE_ID), + candidate( + AllowanceAccountingBlock.SharedRule("no_eligible_endpoint"), + allowanceId = SECOND_ALLOWANCE_ID, + ), ), ) @@ -1568,6 +1571,48 @@ class PaykitAllowanceExecutorTest : BaseUnitTest() { assertEquals(listOf(limitReached), events, "Limit Reached is raised once per request") } + @Test + fun `local capacity check hands back a request whose covering allowances have all ended`() = test { + collectEvents() + sut.updateLocalState(identity) { + it.copy(endedAllowanceIds = setOf(PaykitAllowance.key(fixtures.counterpartyKey, ALLOWANCE_ID))) + } + + val isManual = sut.classifyAboveLimit(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertTrue(isManual) + assertTrue(events.isEmpty(), "Ended allowances raise no Limit Reached") + } + + @Test + fun `a request whose covering allowances have all ended stays manual without Limit Reached`() = test { + collectEvents() + sut.updateLocalState(identity) { + it.copy(endedAllowanceIds = setOf(PaykitAllowance.key(fixtures.counterpartyKey, ALLOWANCE_ID))) + } + val request = fixtures.paymentRequest(amountSats = 20_000uL) + + val result = sut.autoPay(request, listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertTrue(events.isEmpty(), "Ended allowances raise no Limit Reached") + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any(), any()) + verifyNoInteractions(payer) + } + + @Test + fun `a checked allowance the SDK blocks stays manual without Limit Reached when it fits locally`() = test { + collectEvents() + candidates = listOf(candidate(AllowanceAccountingBlock.SharedRule("no_eligible_endpoint"))) + + val result = sut.autoPay(fixtures.paymentRequest(), listOf(fixtures.allowance()), identity) + + assertEquals(PaykitAllowanceAutoPayResult.MANUAL, result) + assertTrue(events.isEmpty(), "A request the allowance still fits raises no Limit Reached") + verify(sdk, never()).acceptPaymentRequestAutomatically(any(), any(), any(), any()) + verifyNoInteractions(payer) + } + @Test fun `local capacity check flags only requests that fit no allowance`() = test { collectEvents() From 28a960106dafece4c09013ce998412fe76a07dfb Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 4 Nov 2026 18:43:26 -0500 Subject: [PATCH 10/11] chore: rename changelog fragment Co-Authored-By: Claude Opus 5.5 --- .../{allowance-pause-after-restore.fixed.md => 1779.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{allowance-pause-after-restore.fixed.md => 1779.fixed.md} (100%) diff --git a/changelog.d/next/allowance-pause-after-restore.fixed.md b/changelog.d/next/1779.fixed.md similarity index 100% rename from changelog.d/next/allowance-pause-after-restore.fixed.md rename to changelog.d/next/1779.fixed.md From f99874a5d2a32e99f96d668c93832af07be07565 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 5 Nov 2026 14:23:21 -0500 Subject: [PATCH 11/11] test: accept the confirm screen for a paused allowance request Co-Authored-By: Claude Opus 5.5 --- journeys/allowances/README.md | 3 ++- journeys/allowances/allowance-paused-after-restore.xml | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 0a73d96888..1d9483665a 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -61,7 +61,8 @@ killed right after handing the payment to the node never paid twice after relaun - Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` - Payments tab: `Tab-payments`, `PaymentRequestCreate`, `PaymentRequestRow-` 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 A payer who requires a PIN for payments sees the PIN pad (or biometrics) over the sheet when saving or ending an allowance, and when accepting an allowance a payee asked for; the payee's answers and automatic payments never diff --git a/journeys/allowances/allowance-paused-after-restore.xml b/journeys/allowances/allowance-paused-after-restore.xml index e7eebe9160..c362515ff5 100644 --- a/journeys/allowances/allowance-paused-after-restore.xml +++ b/journeys/allowances/allowance-paused-after-restore.xml @@ -19,8 +19,8 @@ Open the drawer menu, tap Subscriptions and tap the Allowances tab (testTag "Tab-allowances") Verify the paused notice (testTag "AllowancesPaused") reads "Automatic payments paused" and "This wallet was restored from a backup. Requests ask for your approval until you resume.", shows Resume (testTag "AllowancesResume"), and the payee's row (testTag "AllowanceRow-<entryId>") is listed as Active On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (testTag "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 Payment Request sheet (testTag "PaymentRequestsSheet") opens for the request, or the bell (testTag "PaymentRequestsBell") shows one pending request, and that no "Payment Executed" notification is posted, reading notifications back with `adb shell dumpsys notification --noredact` - Pay it from the sheet (testTag "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay, then verify Bitcoin Sent shows about $2.00 + Switch to the payer instance, with Bitkit in the foreground, and verify the request waits for approval: its confirm screen (testTag "PaymentRequestConfirm") opens with "Paused" (testTag "PaymentRequestFor"), or the Payment Request sheet (testTag "PaymentRequestsSheet") opens for the request, or the bell (testTag "PaymentRequestsBell") shows one pending request, and that no "Payment Executed" notification is posted, reading notifications back with `adb shell dumpsys notification --noredact` + Pay it from the confirm screen, or from the sheet (testTag "PaymentRequestPay-<paymentRequestId>") when the sheet shows, and complete Swipe To Pay, then verify Bitcoin Sent shows about $2.00 Relaunch the payer with `adb shell am force-stop to.bitkit.dev` and `adb shell monkey -p to.bitkit.dev -c android.intent.category.LAUNCHER 1`, open the drawer menu, tap Subscriptions and tap the Allowances tab Verify the paused notice (testTag "AllowancesPaused") still reads "This wallet was restored from a backup. Requests ask for your approval until you resume." Tap Resume (testTag "AllowancesResume") and, if the PIN pad shows, enter the PIN