From b6dd98dd7fb57ce76396c21b33181e4006d4e3e2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:08:56 +0100 Subject: [PATCH 01/28] fix: release a failed automatic payment's list once A settlement and restart recovery could both record one failed attempt and release the payee's payment list twice, and a list released before broadcast was released again when clearing its claim failed to save. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 20 ++-- .../PaykitAllowanceExecutorTests.swift | 95 +++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 812f41938..9a677b238 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -493,7 +493,8 @@ actor PaykitAllowanceExecutor { private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) - let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) + // A settlement and recovery can both record one failure; only the call that ends the attempt releases its list. + let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity, onlyIfOpen: outcome == .failed) if outcome == .failed { await releaseConsumedPaymentList(of: entry) } @@ -764,13 +765,20 @@ actor PaykitAllowanceExecutor { identity: String, outcome: PrivatePaymentListSendOutcome ) async { + var outcome = outcome if outcome == .definitePreBroadcastFailure { // Released here, so recording the attempt failed must not release it again: by then another payment may - // have consumed the same list version. - updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == journalAttemptId }) else { return } - state.journal[index].paymentListVersion = nil - state.journal[index].previousPaymentListVersion = nil + // have consumed the same list version. A claim that cannot be cleared stays for that recording to release. + do { + var state = try store.load(identity: identity) + if let index = state.journal.firstIndex(where: { $0.attemptId == journalAttemptId }) { + state.journal[index].paymentListVersion = nil + state.journal[index].previousPaymentListVersion = nil + try store.save(state, identity: identity) + } + } catch { + Logger.warn("Failed to clear a payment list claim; the failure record releases it: \(error)", context: "PaykitAllowance") + outcome = .uncertain } } await payer.resolvePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: attemptId, outcome: outcome) diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index a76659030..902fec538 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -715,6 +715,70 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released.filter { $0 == 7 }.count, 1, "The second attempt's list is released once, by the settlement") } + func testSettlementNeverReleasesAListRecoveryReleasedWhileItRecorded() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "a", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 5 + )]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + let gate = await harness.sdk.holdNextOutcome() + + let settlement = Task { await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false) } + await Self.waitUntil { harness.log.entries.contains("recordPaymentOutcome") } + await harness.executor.recover(identity: Fixtures.identityKey) + gate.open() + await settlement.value + + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released, [5], "The list is released once, by recovery") + } + + func testRecoveryNeverReleasesAListASettlementReleasedWhileItLookedUpThePayment() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "a", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 5 + )]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([AllowanceHarness.paymentHash: .failed]) + await harness.executor.activate(identity: Fixtures.identityKey) + let gate = await harness.lookup.holdNextLookup() + + let recovery = Task { await harness.executor.recover(identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("lightningStatus") } + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false) + gate.open() + await recovery.value + + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released, [5], "The list is released once, by the settlement") + } + + func testPaymentListIsNotReleasedBeforeBroadcastWhenItsClaimCannotBeCleared() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + await harness.payer.failSavesOnNodeRejection(harness.store) + + _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain], "A claim still saved in the journal must not be released twice") + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released.count, 1, "Recording the failure releases the list once, from the journal") + } + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -1561,8 +1625,20 @@ private actor AllowanceLightningLookupMock: PaykitLightningPaymentProofLookingUp self.statuses = statuses } + private var lookupGate: AllowanceGate? + + func holdNextLookup() -> AllowanceGate { + let gate = AllowanceGate() + lookupGate = gate + return gate + } + func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { log.append("lightningStatus") + if let gate = lookupGate { + lookupGate = nil + await gate.wait() + } return statuses[paymentHash.lowercased()] ?? .unknown } } @@ -1730,8 +1806,15 @@ private actor AllowancePayerMock: PaykitAllowancePaying { } } + private var storeFailingOnNodeRejection: AllowanceMemoryStore? + + func failSavesOnNodeRejection(_ store: AllowanceMemoryStore) { + storeFailingOnNodeRejection = store + } + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { called("failLightningPayment") + storeFailingOnNodeRejection?.failSaves() return lightningErrorIsDefinite } @@ -1978,9 +2061,21 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(id: attemptId, admittedAt: checks.trustedTime, status: .submitted)) } + private var outcomeGate: AllowanceGate? + + func holdNextOutcome() -> AllowanceGate { + let gate = AllowanceGate() + outcomeGate = gate + return gate + } + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord { log.append("recordPaymentOutcome") recordedOutcomes.append(report) + if let gate = outcomeGate { + outcomeGate = nil + await gate.wait() + } let status: Paykit.PaymentExecutionStatus = switch report.outcome { case .succeeded: .succeeded case .failed: .failed From 411b050c4d32700b2791f19a9693f5b6c28ce5c4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:09:13 +0100 Subject: [PATCH 02/28] fix: keep auto-paid labels and totals past 200 allowance attempts The Auto-paid label and the amount paid automatically now come from the Paykit SDK's accounting history, which keeps every attempt, instead of the local journal, which keeps only the newest 200. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 23 +++++++++++++-- Bitkit/Services/PaykitAllowanceManager.swift | 8 +++--- .../PaykitAllowanceExecutorTests.swift | 28 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 9a677b238..03528cf79 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -549,8 +549,27 @@ actor PaykitAllowanceExecutor { return PaykitAllowanceCapacity.attempts(from: state.history) } - func succeededAutomaticPayments(identity: String) -> [PaykitAllowanceLocalState.JournalEntry] { - localState(identity: identity).journal.filter { $0.isAutomatic && $0.stage == .succeeded } + struct AutomaticPayment: Equatable { + let requestId: PaykitPaymentRequest.ID + let allowanceId: String + let amountSats: UInt64 + } + + /// Read from the SDK ledger, which keeps every attempt, rather than the journal, which keeps only the newest ones. + func succeededAutomaticPayments() async -> [AutomaticPayment] { + guard let state = try? await sdk.allowanceAccountingState() else { return [] } + return state.history.occurrences.flatMap { occurrence in + occurrence.attempts.compactMap { attempt in + guard attempt.mode == .automatic, attempt.status == .succeeded, let allowanceId = attempt.allowanceId, + let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value()) + else { return nil } + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: occurrence.key.request.paymentRequestId, + counterparty: occurrence.key.request.counterparty + ) + return AutomaticPayment(requestId: requestId, allowanceId: allowanceId, amountSats: amountSats) + } + } } // MARK: Automatic payment diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index e813a76a5..7ffb327eb 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -160,11 +160,11 @@ final class PaykitAllowanceManager { Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance") } localState = await executor.localState(identity: identity) - let paid = await executor.succeededAutomaticPayments(identity: identity) + let paid = await executor.succeededAutomaticPayments() autoPaidRequestIds = Set(paid.map(\.requestId)) - autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, entry in - guard let allowanceId = entry.allowanceId else { return } - totals[PaykitAllowance.scopedId(counterparty: entry.requestId.counterparty, allowanceId: allowanceId), default: 0] += entry.amountSats + autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, payment in + totals[PaykitAllowance.scopedId(counterparty: payment.requestId.counterparty, allowanceId: payment.allowanceId), default: 0] += + payment.amountSats } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 902fec538..7685a166a 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -505,8 +505,6 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: "attempt-1", outcome: .succeeded)]) let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal XCTAssertEqual(journal.map(\.stage), [.succeeded]) - let paid = await harness.executor.succeededAutomaticPayments(identity: Fixtures.identityKey) - XCTAssertEqual(paid.map(\.attemptId), ["attempt-1"]) let paidEvents = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: AllowanceHarness.paymentHash) } @@ -1062,6 +1060,32 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) } + @MainActor + func testManagerKeepsEveryAutomaticPaymentPastTheJournalLimit() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let paid = try (0 ..< 250).map { index in + try Fixtures.occurrence(requestId: "req-\(index)", attempts: [Fixtures.attemptRecord(id: "paid-\(index)", status: .succeeded)]) + } + let notPaid = try [ + Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + Fixtures.occurrence( + requestId: "req-manual", + attempts: [Fixtures.attemptRecord(id: "manual", mode: .manual, allowanceId: nil, status: .succeeded)], + allowanceId: nil + ), + ] + await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: paid + notPaid)) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertEqual(manager.autoPaidRequestIds.count, 250, "The journal keeps only the newest 200 attempts; the SDK ledger keeps all") + XCTAssertTrue(manager.autoPaidRequestIds.contains(PaykitPaymentRequest.ID(paymentRequestId: "req-0", counterparty: Fixtures.counterpartyKey))) + let entry = try XCTUnwrap(manager.entries.first) + XCTAssertEqual(manager.autoPaidSats(for: entry), 250 * 1000) + } + @MainActor func testManagerLeavesRequestsNotPricedInBitcoinManual() async throws { let harness = AllowanceHarness() From d3ce74b47dd0b7a00813152bcaa02d83c070ca1a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:09:25 +0100 Subject: [PATCH 03/28] fix: keep a bitcoin request an allowance can't pay over lightning manual A bitcoin request whose accepted endpoints are only USDT or on-chain was held for an allowance that can never pay it, waiting on a usable channel and keeping it off the Payment Request sheet. Coverage now requires an accepted Lightning endpoint. --- Bitkit/Services/PaykitAllowanceManager.swift | 9 +++++++- .../PaykitAllowanceExecutorTests.swift | 22 +++++++++++++++++++ BitkitTests/PaykitAllowanceTests.swift | 5 +++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 7ffb327eb..b861816e0 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -175,7 +175,9 @@ final class PaykitAllowanceManager { /// Whether an incoming request is covered by an active Allowance this wallet granted. A request created before the /// Allowance was accepted stays manual: it may already have been shown to the user for a decision. func coversRequest(_ request: PaykitPaymentRequest) -> Bool { - guard request.isAllowancePayable else { return false } + guard request.isAllowancePayable, + request.acceptedPaymentEndpointIdentifiers.contains(where: Self.automaticPaymentEndpointIdentifiers.contains) + else { return false } return allowances.contains { allowance in guard allowance.isAllower, allowance.status(at: now()) == .active, PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty), let acceptedAt = allowance.lastEventAt, @@ -396,6 +398,11 @@ final class PaykitAllowanceManager { static let acceptanceClockTolerance: TimeInterval = 30 + /// An on-chain miner fee falls outside an allowance's limits, so only a Lightning endpoint pays automatically. + static let automaticPaymentEndpointIdentifiers: Set = Set(allowedPaymentEndpointIdentifiers.filter { + $0 == PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue || $0 == PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue + }) + static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( PublicPaykitService.MethodId.publishableMethodIds.filter { $0 != .usdtArbitrum }.map(\.rawValue), network: Env.network diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 7685a166a..45735ddc3 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1086,6 +1086,28 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(manager.autoPaidSats(for: entry), 250 * 1000) } + @MainActor + func testManagerLeavesABitcoinRequestWithoutALightningEndpointManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + let usdtOnly = try Fixtures.paymentRequest( + conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")]), + acceptedEndpoints: [PublicPaykitService.MethodId.usdtArbitrum.rawValue] + ) + let onchainOnly = try Fixtures.paymentRequest(acceptedEndpoints: [PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue]) + + for request in [usdtOnly, onchainOnly] { + XCTAssertFalse(manager.coversRequest(request), "An allowance pays over Lightning only") + let isHandling = await manager.isAutomaticallyHandling(request) + XCTAssertFalse(isHandling, "The request reaches the Payment Request sheet instead of waiting for a channel") + } + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(acceptedEndpoints: [ + PublicPaykitService.MethodId.usdtArbitrum.rawValue, Fixtures.lightningIdentifier, + ]))) + } + @MainActor func testManagerLeavesRequestsNotPricedInBitcoinManual() async throws { let harness = AllowanceHarness() diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 0a6947f63..3b2742282 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -603,7 +603,8 @@ enum PaykitAllowanceFixtures { amount: String = "0.00001", asset: String = PaykitIssuerInterop.bitcoinAsset, conversion: Paykit.PaymentConversion? = nil, - createdAt: String = "2026-09-24T11:00:00Z" + createdAt: String = "2026-09-24T11:00:00Z", + acceptedEndpoints: [String] = [lightningIdentifier] ) throws -> PaykitPaymentRequest { let record = try Paykit.PaymentRequestRecord( counterparty: counterparty, @@ -622,7 +623,7 @@ enum PaykitAllowanceFixtures { paymentReference: Paykit.PaymentReference(text: "invoice-123"), proposalExpiresAt: nil, recurrence: nil, - acceptedPaymentEndpointIdentifiers: [lightningIdentifier], + acceptedPaymentEndpointIdentifiers: acceptedEndpoints, paymentEndpoints: nil, requiredAppId: "bitkit", conversion: conversion, From 6f7354a73161d80dec2ed7ab1af2cbdff8322454 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:09:50 +0100 Subject: [PATCH 04/28] fix: load allowance contacts and identity once the paykit session appears Signing in, restoring or ending a profile deletion turns authState authenticated while the session change is still in flight, so allowances never got their identity and contacts never loaded until Contacts was opened. The profile manager now announces when a session change settles, and the app activates allowances and loads contacts then. --- Bitkit/AppScene.swift | 56 ++++++++++++++-------- Bitkit/Managers/PubkyProfileManager.swift | 12 +++++ BitkitTests/PubkyProfileManagerTests.swift | 24 ++++++++++ 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5e17f7792..b27f7d0e8 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -365,6 +365,7 @@ struct AppScene: View { activityChanges: CoreService.shared.activity.activitiesChangedPublisher ) @State private var paykitAllowanceManager = PaykitAllowanceManager() + @State private var activatedAutomaticPaykitIdentity: String? @State private var hideSplash = false @State private var removeSplash = false @@ -611,33 +612,23 @@ struct AppScene: View { receivedPaymentBackfillCache.invalidate() if authState == .authenticated, let pk = pubkyProfile.publicKey { paykitPaymentRequestManager.activate(identity: pk) - Task { - guard let session = pubkyProfile.automaticPaykitSession, - PubkyPublicKeyFormat.matches(pk, session.publicKey) - else { return } - await paykitAllowanceManager.activate(identity: pk) - await handlePendingPaykitSubscriptionNotification() - guard pubkyProfile.automaticPaykitSession == session else { return } - try? await contactsManager.loadContacts(for: pk) - if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { - await retryPendingPaykitEndpointRemoval() - } - guard isPaykitSceneActive, pubkyProfile.authState == .authenticated, - PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey) - else { return } - await refreshPrivateOnlyPaykitApp() - await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true) - if PaykitSubscriptionNotificationTargetStore.load() == nil { - await presentNextIncomingPaykitItem() - } - } + startAutomaticPaykitSession() } else if authState == .idle { + activatedAutomaticPaykitIdentity = nil contactsManager.reset() paykitPaymentRequestManager.clear() paykitAllowanceManager.deactivate() hasPendingExpiryToast = false } } + // A sign-in, or a session that was still changing or being deleted, has no automatic session yet when + // authState turns authenticated; activate allowances and load contacts once it appears. + .onReceive(PubkyProfileManager.sessionSettledPublisher) { startAutomaticPaykitSession() } + .onChange(of: pubkyProfile.isDeletingProfile) { _, isDeleting in + if !isDeleting { + startAutomaticPaykitSession() + } + } .onReceive(contactsManager.savedContactsChangedPublisher) { contacts in let publicKeys = contacts.map(\.publicKey) guard PaykitFeatureFlags.isUIEnabled, @@ -2016,6 +2007,31 @@ struct AppScene: View { } } + private func startAutomaticPaykitSession() { + guard let session = pubkyProfile.automaticPaykitSession, + !PubkyPublicKeyFormat.matches(session.publicKey, activatedAutomaticPaykitIdentity) + else { return } + let pk = session.publicKey + activatedAutomaticPaykitIdentity = pk + Task { + await paykitAllowanceManager.activate(identity: pk) + await handlePendingPaykitSubscriptionNotification() + guard pubkyProfile.automaticPaykitSession == session else { return } + try? await contactsManager.loadContacts(for: pk) + if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { + await retryPendingPaykitEndpointRemoval() + } + guard isPaykitSceneActive, pubkyProfile.authState == .authenticated, + PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey) + else { return } + await refreshPrivateOnlyPaykitApp() + await refreshIncomingPaykitPaymentRequests(presentItems: false, onlyWhileActive: true) + if PaykitSubscriptionNotificationTargetStore.load() == nil { + await presentNextIncomingPaykitItem() + } + } + } + private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) { for offer in offers { let title = t("subscriptions__allowance_offer_accepted_title") diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 52b60f8cf..65fbb6bfb 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import enum Paykit.PaykitError import struct Paykit.PubkySessionBootstrapResult @@ -145,6 +146,17 @@ class PubkyProfileManager: ObservableObject { private static func endSessionMutation() { sessionMutationCount -= 1 + if sessionMutationCount == 0 { + sessionSettledSubject.send() + } + } + + private static let sessionSettledSubject = PassthroughSubject() + + /// Fires when the last session change in flight ends. `currentSession` reads nil until then, and nothing published + /// changes at that moment, so work that needs the session (a sign-in completing during that change) waits for this. + static var sessionSettledPublisher: AnyPublisher { + sessionSettledSubject.eraseToAnyPublisher() } init( diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 91465c895..d1e8c84ba 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import Combine import enum Paykit.PaykitError import class Paykit.PubkySessionAccess import struct Paykit.PubkySessionBootstrapResult @@ -279,6 +280,29 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testAutomaticPaykitSessionAppearsWhenTheSessionChangeEndsAndIsAnnounced() async throws { + try await withEmptyIdentityStorage { + let secret = String(repeating: "01", count: 32) + let manager = PubkyProfileManager() + manager.publicKey = try PubkyProfileManager.publicKeyFromSecretKey(secret) + manager.authState = .authenticated + var sessionsWhenSettled: [PubkyProfileManager.SignedInSession?] = [] + let subscription = PubkyProfileManager.sessionSettledPublisher.sink { sessionsWhenSettled.append(manager.automaticPaykitSession) } + defer { subscription.cancel() } + var sessionDuringChange: PubkyProfileManager.SignedInSession? + + _ = try? await manager.adoptRingIdentity(pubky: XCTUnwrap(manager.publicKey), loadSecret: { _, _ in + sessionDuringChange = manager.automaticPaykitSession + return nil + }) + + XCTAssertNil(sessionDuringChange, "No automatic session while a session change is in flight, though authState reads authenticated") + XCTAssertEqual(sessionsWhenSettled.count, 1) + XCTAssertNotNil(sessionsWhenSettled.first ?? nil, "The session is readable when the change is announced as settled") + } + } + @MainActor func testDeletionSessionRejectsAReplacementAndANewSessionForTheSameKey() async throws { try await withEmptyIdentityStorage { From 114dcaa2643d0c56c1be419fecce5a83312f2143 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:10:10 +0100 Subject: [PATCH 05/28] fix: announce every allowance the wallet accepts by itself An offer is marked before the automatic accept and unmarked once announced, so an accept that a stop or a cancelled refresh cut short is announced on the next refresh. Offers accepted in one refresh share one announcement, since each fallback toast replaced the one before it. The shared allowance journeys now say the payee's wallet accepts by itself. --- Bitkit/AppScene.swift | 19 ++++++---- .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + Bitkit/Services/PaykitAllowanceExecutor.swift | 2 + Bitkit/Services/PaykitAllowanceManager.swift | 32 +++++++++++----- .../PaykitAllowanceExecutorTests.swift | 37 +++++++++++++++++++ journeys/allowances/end-stops-auto-pay.xml | 2 +- journeys/allowances/monthly-cap-reached.xml | 2 +- .../pin-gates-allowance-changes.xml | 3 +- 22 files changed, 93 insertions(+), 19 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b27f7d0e8..ae6af5c90 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -2032,14 +2032,19 @@ struct AppScene: View { } } + /// One announcement for all offers accepted in a refresh, since each fallback toast would replace the one before it. private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) { - for offer in offers { - let title = t("subscriptions__allowance_offer_accepted_title") - let description = t("subscriptions__allowance_offer_accepted_description", variables: ["name": contactName(offer.counterparty)]) - PaykitAllowanceNotifier.post(title: title, body: description, fallback: { - app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast") - }) - } + guard let first = offers.first else { return } + let title = t("subscriptions__allowance_offer_accepted_title") + let description = offers.count == 1 + ? t("subscriptions__allowance_offer_accepted_description", variables: ["name": contactName(first.counterparty)]) + : t( + "subscriptions__allowance_offers_accepted_description", + variables: ["names": ListFormatter.localizedString(byJoining: offers.map { contactName($0.counterparty) })] + ) + PaykitAllowanceNotifier.post(title: title, body: description, fallback: { + app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast") + }) } private func contactName(_ publicKey: String) -> String { diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index b0269c7fd..c8333e777 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1635,6 +1635,7 @@ "subscriptions__allowance_executed_description" = "أرسل الدفع التلقائي {amount} إلى {name}"; "subscriptions__allowance_offer_accepted_title" = "تمت إضافة مخصص"; "subscriptions__allowance_offer_accepted_description" = "أعدّ {name} مخصصًا لك"; +"subscriptions__allowance_offers_accepted_description" = "أعدّ {names} مخصصات لك"; "subscriptions__allowance_limit_title" = "تم بلوغ الحد"; "subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع."; "subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 29eec5810..50825c16e 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1819,6 +1819,7 @@ "subscriptions__allowance_executed_description" = "El pagament automàtic ha enviat {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Assignació afegida"; "subscriptions__allowance_offer_accepted_description" = "{name} ha configurat una assignació per a tu"; +"subscriptions__allowance_offers_accepted_description" = "{names} han configurat assignacions per a tu"; "subscriptions__allowance_limit_title" = "Límit assolit"; "subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar."; "subscriptions__allowance_auto_paid" = "Pagat automàticament"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 8d813a89d..c549f4338 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1958,6 +1958,7 @@ "subscriptions__allowance_executed_description" = "Automatická platba odeslala {amount} uživateli {name}"; "subscriptions__allowance_offer_accepted_title" = "Povolení plateb přidáno"; "subscriptions__allowance_offer_accepted_description" = "Uživatel {name} vám nastavil povolení plateb"; +"subscriptions__allowance_offers_accepted_description" = "Uživatelé {names} vám nastavili povolení plateb"; "subscriptions__allowance_limit_title" = "Limit dosažen"; "subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte."; "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index cfcd3dccc..39214490c 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1955,6 +1955,7 @@ "subscriptions__allowance_executed_description" = "Auto-Pay hat {amount} an {name} gesendet"; "subscriptions__allowance_offer_accepted_title" = "Kontingent hinzugefügt"; "subscriptions__allowance_offer_accepted_description" = "{name} hat ein Kontingent für dich eingerichtet"; +"subscriptions__allowance_offers_accepted_description" = "{names} haben Kontingente für dich eingerichtet"; "subscriptions__allowance_limit_title" = "Limit erreicht"; "subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen."; "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index f4bc31454..af300ba76 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1690,6 +1690,7 @@ "subscriptions__allowance_executed_description" = "Η αυτόματη πληρωμή έστειλε {amount} σε {name}"; "subscriptions__allowance_offer_accepted_title" = "Η προέγκριση προστέθηκε"; "subscriptions__allowance_offer_accepted_description" = "Ο χρήστης {name} όρισε μια προέγκριση για εσάς"; +"subscriptions__allowance_offers_accepted_description" = "Οι χρήστες {names} όρισαν προεγκρίσεις για εσάς"; "subscriptions__allowance_limit_title" = "Το όριο εξαντλήθηκε"; "subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις."; "subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 5a3c17e2f..ef606bb58 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1832,6 +1832,7 @@ "subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay."; "subscriptions__allowance_offer_accepted_title" = "Allowance Added"; "subscriptions__allowance_offer_accepted_description" = "{name} set up an allowance for you"; +"subscriptions__allowance_offers_accepted_description" = "{names} set up allowances for you"; "subscriptions__allowance_auto_paid" = "Auto-paid"; // On-chain acceptance and original-payment follow-up diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 4a6cc334b..92e8ccfe5 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1961,6 +1961,7 @@ "subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Asignación agregada"; "subscriptions__allowance_offer_accepted_description" = "{name} configuró una asignación para ti"; +"subscriptions__allowance_offers_accepted_description" = "{names} configuraron asignaciones para ti"; "subscriptions__allowance_limit_title" = "Límite alcanzado"; "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 47cd807dc..3a6019bee 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1839,6 +1839,7 @@ "subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Asignación añadida"; "subscriptions__allowance_offer_accepted_description" = "{name} ha configurado una asignación para ti"; +"subscriptions__allowance_offers_accepted_description" = "{names} han configurado asignaciones para ti"; "subscriptions__allowance_limit_title" = "Límite alcanzado"; "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 2535a6350..f886d4168 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1969,6 +1969,7 @@ "subscriptions__allowance_executed_description" = "Le paiement automatique a envoyé {amount} à {name}"; "subscriptions__allowance_offer_accepted_title" = "Allocation ajoutée"; "subscriptions__allowance_offer_accepted_description" = "{name} a configuré une allocation pour vous"; +"subscriptions__allowance_offers_accepted_description" = "{names} ont configuré des allocations pour vous"; "subscriptions__allowance_limit_title" = "Limite atteinte"; "subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer."; "subscriptions__allowance_auto_paid" = "Payé automatiquement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 8b73e25a9..adb6092b8 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1928,6 +1928,7 @@ "subscriptions__allowance_executed_description" = "Il pagamento automatico ha inviato {amount} a {name}"; "subscriptions__allowance_offer_accepted_title" = "Plafond Aggiunto"; "subscriptions__allowance_offer_accepted_description" = "{name} ha impostato un Plafond per te"; +"subscriptions__allowance_offers_accepted_description" = "{names} hanno impostato dei Plafond per te"; "subscriptions__allowance_limit_title" = "Limite Raggiunto"; "subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare."; "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 1bf09b447..f261479b8 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1963,6 +1963,7 @@ "subscriptions__allowance_executed_description" = "Automatisch betalen heeft {amount} naar {name} gestuurd"; "subscriptions__allowance_offer_accepted_title" = "Toelage toegevoegd"; "subscriptions__allowance_offer_accepted_description" = "{name} heeft een toelage voor je ingesteld"; +"subscriptions__allowance_offers_accepted_description" = "{names} hebben toelagen voor je ingesteld"; "subscriptions__allowance_limit_title" = "Limiet bereikt"; "subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen."; "subscriptions__allowance_auto_paid" = "Automatisch betaald"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1df98c9fd..d4ff64d8c 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1968,6 +1968,7 @@ "subscriptions__allowance_executed_description" = "Autopłatność wysłała {amount} do {name}"; "subscriptions__allowance_offer_accepted_title" = "Przydział dodany"; "subscriptions__allowance_offer_accepted_description" = "Użytkownik {name} ustawił dla Ciebie przydział"; +"subscriptions__allowance_offers_accepted_description" = "Użytkownicy {names} ustawili dla Ciebie przydziały"; "subscriptions__allowance_limit_title" = "Osiągnięto limit"; "subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić."; "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 6b4158f28..b766ae0f9 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1969,6 +1969,7 @@ "subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; "subscriptions__allowance_offer_accepted_title" = "Pré-aprovação Adicionada"; "subscriptions__allowance_offer_accepted_description" = "{name} configurou uma pré-aprovação para você"; +"subscriptions__allowance_offers_accepted_description" = "{names} configuraram pré-aprovações para você"; "subscriptions__allowance_limit_title" = "Limite Atingido"; "subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 322ddc8fb..29a688eb5 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1615,6 +1615,7 @@ "subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; "subscriptions__allowance_offer_accepted_title" = "Pré-aprovação adicionada"; "subscriptions__allowance_offer_accepted_description" = "{name} configurou uma pré-aprovação para si"; +"subscriptions__allowance_offers_accepted_description" = "{names} configuraram pré-aprovações para si"; "subscriptions__allowance_limit_title" = "Limite atingido"; "subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c7d64f169..db4b22908 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1949,6 +1949,7 @@ "subscriptions__allowance_executed_description" = "Автооплата отправила {amount} пользователю {name}"; "subscriptions__allowance_offer_accepted_title" = "Лимит автооплаты добавлен"; "subscriptions__allowance_offer_accepted_description" = "Пользователь {name} настроил для вас лимит автооплаты"; +"subscriptions__allowance_offers_accepted_description" = "Пользователи {names} настроили для вас лимиты автооплаты"; "subscriptions__allowance_limit_title" = "Лимит Достигнут"; "subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить."; "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 03528cf79..8527276f5 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -87,6 +87,8 @@ struct PaykitAllowanceLocalState: Codable, Equatable { var groups: [Group] = [] var journal: [JournalEntry] = [] var presentedProposalIds: Set = [] + /// Offers this wallet accepts by itself, marked before the accept, until "Allowance Added" is shown for them. + var unannouncedOfferIds: Set = [] var notifiedRequestIds: Set = [] var lastTrustedTime: Date? diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index b861816e0..e0eae5495 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -259,33 +259,47 @@ final class PaykitAllowanceManager { /// Accepts the offers that make this wallet the allowee, and returns the ones now active so the caller can tell the /// user. A failed accept stays unanswered and is tried again on the next refresh. `canContinue` gates each further - /// accept; an accept already made is still returned while this identity is active and `canAnnounce` holds, since - /// the offer is no longer answerable afterwards and nothing else would tell the user about it. + /// accept. Each offer is marked before its accept and unmarked only when returned, so an accept whose announcement a + /// stop or a cancelled refresh cut short is returned by a later call, while this identity is active and `canAnnounce` + /// holds: the offer is no longer answerable afterwards and nothing else would tell the user about it. func acceptOffersFromAllowers( canContinue: @MainActor () -> Bool = { true }, canAnnounce: @MainActor () -> Bool = { true } ) async -> [PaykitAllowanceEntry] { guard let identity, !isAcceptingOffers else { return [] } + isAcceptingOffers = true + defer { isAcceptingOffers = false } + let offers = entries.filter { entry in let answerable = entry.allowances.filter(\.isAnswerable) return !answerable.isEmpty && answerable.allSatisfy(\.isOfferFromAllower) } - guard !offers.isEmpty else { return [] } - - isAcceptingOffers = true - defer { isAcceptingOffers = false } - var accepted: [PaykitAllowanceEntry] = [] for offer in offers { guard self.identity == identity, canContinue() else { break } + let ids = offer.allowances.map(\.scopedId) + await executor.updateLocalState(identity: identity) { $0.unannouncedOfferIds.formUnion(ids) } do { // A background accept must not clear the progress flag of a proposal the user has in flight. try await accept(offer, showsProgress: false) - accepted.append(offer) } catch { Logger.warn("Failed to accept an allowance offer: \(error)", context: "PaykitAllowance") } } - return self.identity == identity && canAnnounce() ? accepted : [] + guard self.identity == identity, canAnnounce() else { return [] } + return await takeUnannouncedOffers(identity: identity) + } + + private func takeUnannouncedOffers(identity: String) async -> [PaykitAllowanceEntry] { + let marked = await executor.localState(identity: identity).unannouncedOfferIds + guard !marked.isEmpty else { return [] } + let accepted = entries.filter { entry in + entry.allowances.contains { marked.contains($0.scopedId) && $0.role == .allowee && $0.lifecycleState == .accepted } + } + // An offer still unanswered keeps its mark; one ended or withdrawn before the accept will never be announced. + let settled = Set(allowances.filter { $0.lifecycleState != .proposed }.map(\.scopedId)) + await executor.updateLocalState(identity: identity) { $0.unannouncedOfferIds.subtract(settled) } + localState.unannouncedOfferIds.subtract(settled) + return accepted } private func respond( diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 45735ddc3..41b5db387 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1331,6 +1331,34 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(acceptedIds, [Fixtures.walletAllowanceId]) } + @MainActor + func testManagerAnnouncesAnOfferWhoseAcceptWasCutShortOnTheNextRefresh() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + await harness.sdk.setAcceptAllowanceErrorAfterCommit(CancellationError()) + + let cutShort = await manager.acceptOffersFromAllowers() + + XCTAssertTrue(cutShort.isEmpty) + let acceptedIds = await harness.sdk.acceptedAllowanceIds + XCTAssertEqual(acceptedIds, [Fixtures.walletAllowanceId], "The SDK committed the accept before the call stopped") + + await harness.sdk.setAcceptAllowanceErrorAfterCommit(nil) + let relaunched = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await relaunched.activate(identity: Fixtures.identityKey) + let announced = await relaunched.acceptOffersFromAllowers() + let announcedAgain = await relaunched.acceptOffersFromAllowers() + + XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey], "The accepted offer is announced on the next refresh") + XCTAssertTrue(announcedAgain.isEmpty, "and only once") + let acceptedIdsAfter = await harness.sdk.acceptedAllowanceIds + XCTAssertEqual(acceptedIdsAfter, [Fixtures.walletAllowanceId]) + } + @MainActor func testAutomaticOfferAcceptanceKeepsAManualProposalWorking() async throws { let harness = AllowanceHarness() @@ -1980,6 +2008,12 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { return record } + private var acceptAllowanceErrorAfterCommit: Error? + + func setAcceptAllowanceErrorAfterCommit(_ error: Error?) { + acceptAllowanceErrorAfterCommit = error + } + func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord { log.append("acceptAllowance") if let acceptAllowanceError { @@ -1991,6 +2025,9 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { } records[index].state = .accepted acceptedAllowanceIds.append(allowanceId) + if let acceptAllowanceErrorAfterCommit { + throw acceptAllowanceErrorAfterCommit + } return records[index] } diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml index f3003d79f..a37018526 100644 --- a/journeys/allowances/end-stops-auto-pay.xml +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -16,7 +16,7 @@ On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (id "PaymentRequestsBell") as an ordinary Payment Request Dismiss that request On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" - Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + Set a fresh $5 a payment, $50 a month allowance between the same two instances and let the payee's wallet accept it, per set-and-accept.xml On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end Verify the payee's row reads "Ended" On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml index 1b32ebdd3..087d9fa44 100644 --- a/journeys/allowances/monthly-cap-reached.xml +++ b/journeys/allowances/monthly-cap-reached.xml @@ -8,7 +8,7 @@ Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted - On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"), and have the payee accept it + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"), and wait for the payee's wallet to accept it by itself Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml index d49428b1c..97b0ea7f1 100644 --- a/journeys/allowances/pin-gates-allowance-changes.xml +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -20,7 +20,8 @@ Verify the Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no "Allowance Added" notification Tap Save Allowance again and enter the PIN Verify the sheet dismisses and the payer's row reads "Waiting for an answer" - On the payee instance, verify "Allowance Added" arrives with no review sheet (id "AllowanceReview"), no tap and no PIN, and the payer's row turns Active + On the payee instance, verify "Allowance Added" arrives with no review sheet (id "AllowanceReview"), no tap and no PIN + On the payer instance, verify the payee's row turns Active On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end Verify the PIN pad shows, press back, and verify the detail sheet (id "AllowanceDetail") is back with the swipe control at its start and the allowance still Active From 9d096f6da9342cd80d933b2932b25ebeabde4b29 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:10:16 +0100 Subject: [PATCH 06/28] fix: say an allowance PIN check confirms the allowance change Setting, answering or ending an allowance asked for the PIN on the payment PIN screen, whose text said it confirms sending a payment. Those checks now say they confirm an allowance change. --- Bitkit/Resources/Localization/ar.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/ca.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/cs.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/de.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/el.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/en.lproj/Localizable.strings | 1 + .../Localization/es-419.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/es.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/fr.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/it.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/nl.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pl.lproj/Localizable.strings | 1 + .../Resources/Localization/pt-BR.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/pt.lproj/Localizable.strings | 1 + Bitkit/Resources/Localization/ru.lproj/Localizable.strings | 1 + Bitkit/Views/Security/PaymentAuthorization.swift | 7 +++++++ Bitkit/Views/Subscriptions/AllowancesView.swift | 6 +++--- Bitkit/Views/Wallets/Send/SendPinScreen.swift | 3 ++- journeys/allowances/pin-gates-allowance-changes.xml | 2 +- 19 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index c8333e777..631e2f3cb 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -539,6 +539,7 @@ "security__pin_forgot_text" = "هل نسيت رمز PIN؟ أعد التعيين واستعد محفظة Bitkit باستخدام عبارة الاسترداد. قم بتعيين رمز PIN جديد بعد إكمال الاستعادة."; "security__pin_forgot_reset" = "إعادة التعيين (يتطلب عبارة الاسترداد)"; "security__pin_send" = "يرجى إدخال رمز PIN الخاص بك لتأكيد وإرسال هذه الدفعة."; +"security__pin_allowance" = "يرجى إدخال رمز PIN الخاص بك لتأكيد هذا التغيير في المخصص."; "security__pin_send_title" = "أدخل رمز PIN"; "security__pin_use_biometrics" = "استخدام {biometricsName}"; "security__bio" = "المقاييس الحيوية"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 50825c16e..8081adc6d 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -613,6 +613,7 @@ "security__pin_forgot_text" = "Has oblidat el PIN? Restableix i recupera la teva cartera Bitkit amb la teva frase de recuperació. Configura un nou PIN després de completar la recuperació."; "security__pin_forgot_reset" = "Restablir (Es necessita la frase de recuperació)"; "security__pin_send" = "Si us plau, introdueix el teu codi PIN per confirmar i enviar aquest pagament."; +"security__pin_allowance" = "Si us plau, introdueix el teu codi PIN per confirmar aquest canvi de l'assignació."; "security__pin_send_title" = "Introdueix el codi PIN"; "security__pin_use_biometrics" = "Utilitzar {biometricsName}"; "security__bio" = "Biometria"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index c549f4338..a6e99c7a1 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -655,6 +655,7 @@ "security__pin_forgot_text" = "Zapomněli jste svůj PIN? Resetujte a obnovte svou peněženku Bitkit pomocí vaší obnovovací fráze. Po dokončení obnovení nastavte nový PIN."; "security__pin_forgot_reset" = "Resetovat (vyžaduje obnovovací frázi)"; "security__pin_send" = "Pro potvrzení a odeslání této platby prosím zadejte svůj PIN kód."; +"security__pin_allowance" = "Pro potvrzení této změny povolení plateb prosím zadejte svůj PIN kód."; "security__pin_send_title" = "Zadejte kód PIN"; "security__pin_use_biometrics" = "Použít {biometricsName}"; "security__bio" = "Biometrické údaje"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 39214490c..1f411bc91 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -653,6 +653,7 @@ "security__pin_forgot_text" = "PIN vergessen? Setze hier dein Bitkit-Wallet zurück und stelle es mit deiner Wiederherstellungsphrase wieder her. Vergebe eine neue PIN nach deiner Wiederherstellung."; "security__pin_forgot_reset" = "Zurücksetzen (erfordert Wiederherstellungsphrase)"; "security__pin_send" = "Bitte gib deinen PIN-Code ein, um diese Zahlung zu bestätigen und zu senden."; +"security__pin_allowance" = "Bitte gib deinen PIN-Code ein, um diese Änderung des Kontingents zu bestätigen."; "security__pin_send_title" = "PIN-Code eingeben"; "security__pin_use_biometrics" = "Verwende {biometricsName}"; "security__bio" = "Biometrie"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index af300ba76..b5ef9c7f2 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -569,6 +569,7 @@ "security__pin_forgot_text" = "Ξεχάσατε το PIN σας; Επαναφέρετε και ανακτήστε το πορτοφόλι Bitkit σας με τη φράση ανάκτησής σας. Ορίστε νέο PIN μετά την ολοκλήρωση της ανάκτησης."; "security__pin_forgot_reset" = "Επαναφορά (Απαιτείται Φράση Ανάκτησης)"; "security__pin_send" = "Παρακαλώ εισάγετε τον κωδικό PIN σας για να επιβεβαιώσετε και να στείλετε αυτή την πληρωμή."; +"security__pin_allowance" = "Παρακαλώ εισάγετε τον κωδικό PIN σας για να επιβεβαιώσετε αυτή την αλλαγή της προέγκρισης."; "security__pin_send_title" = "Εισάγετε Κωδικό PIN"; "security__pin_use_biometrics" = "Χρήση {biometricsName}"; "security__bio" = "Βιομετρικά"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index ef606bb58..e9d820535 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -644,6 +644,7 @@ "security__pin_forgot_text" = "Forgot your PIN? Reset and recover your Bitkit wallet with your recovery phrase. Set a new PIN after completing recovery."; "security__pin_forgot_reset" = "Reset (Requires Recovery Phrase)"; "security__pin_send" = "Please enter your PIN code to confirm and send out this payment."; +"security__pin_allowance" = "Please enter your PIN code to confirm this allowance change."; "security__pin_send_title" = "Enter PIN Code"; "security__pin_use_biometrics" = "Use {biometricsName}"; "security__bio" = "Biometrics"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 92e8ccfe5..b8c87d4a5 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -656,6 +656,7 @@ "security__pin_forgot_text" = "¿Olvidaste tu PIN? Restablece y recupera tu billetera de Bitkit con tu frase de recuperación. Establece un nuevo PIN al finalizar."; "security__pin_forgot_reset" = "Resetear (requiere frase de recuperación)"; "security__pin_send" = "Por favor, introduzca su código PIN para confirmar y enviar este pago."; +"security__pin_allowance" = "Por favor, introduzca su código PIN para confirmar este cambio en la asignación."; "security__pin_send_title" = "Introduzca su PIN"; "security__pin_use_biometrics" = "Usar {biometricsName}"; "security__bio" = "Biométricos"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 3a6019bee..8061c612d 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -614,6 +614,7 @@ "security__pin_forgot_text" = "¿Ha olvidado su PIN? Reinicie y recupere su monedero Bitkit con su frase de recuperación. Establezca un nuevo PIN después de completar la recuperación."; "security__pin_forgot_reset" = "Restablecer (requiere frase de recuperación)"; "security__pin_send" = "Por favor, introduzca su código PIN para confirmar y enviar este pago."; +"security__pin_allowance" = "Por favor, introduzca su código PIN para confirmar este cambio en la asignación."; "security__pin_send_title" = "Introduzca el Código PIN"; "security__pin_use_biometrics" = "Utilizar {biometricsName}"; "security__bio" = "Biometría"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index f886d4168..359fcae29 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -656,6 +656,7 @@ "security__pin_forgot_text" = "Vous avez oublié votre code PIN ? Réinitialisez et récupérez votre portefeuille Bitkit avec votre phrase de récupération. Définissez un nouveau code PIN après avoir terminé la récupération."; "security__pin_forgot_reset" = "Réinitialisation (nécessite une phrase de récupération)"; "security__pin_send" = "Veuillez saisir votre code PIN pour confirmer et envoyer ce paiement."; +"security__pin_allowance" = "Veuillez saisir votre code PIN pour confirmer cette modification de l'allocation."; "security__pin_send_title" = "Entrer code PIN"; "security__pin_use_biometrics" = "Utiliser {biometricsName}"; "security__bio" = "Biométrie"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index adb6092b8..f5dae27b5 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -647,6 +647,7 @@ "security__pin_forgot_text" = "Ha dimenticato il PIN? Resetta e recupera il tuo wallet Bitkit con la recovery phrase. Imposta un nuovo PIN dopo aver completato il recupero."; "security__pin_forgot_reset" = "Reset (Richiede la Recovery Phrase)"; "security__pin_send" = "Inserisci il codice PIN per confermare e inviare questo pagamento."; +"security__pin_allowance" = "Inserisci il codice PIN per confermare questa modifica del Plafond."; "security__pin_send_title" = "Inserisci Codice PIN"; "security__pin_use_biometrics" = "Utilizza {biometricsName}"; "security__bio" = "Biometria"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index f261479b8..773195473 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -653,6 +653,7 @@ "security__pin_forgot_text" = "Uw pincode vergeten? Reset en herstel uw Bitkit wallet met de herstelwoorden. Na het herstel kunt u een niewe pincode configureren."; "security__pin_forgot_reset" = "Reset (Vereist de Herstelwooden)"; "security__pin_send" = "Voer uw pincode in om deze betaling te bevestigen en versturen."; +"security__pin_allowance" = "Voer uw pincode in om deze wijziging van de toelage te bevestigen."; "security__pin_send_title" = "Voer Pincode In"; "security__pin_use_biometrics" = "Gebruik {biometricsName}"; "security__bio" = "Biometrie"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index d4ff64d8c..fbd928fab 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -655,6 +655,7 @@ "security__pin_forgot_text" = "Zapomniałeś kodu PIN? Zresetuj i odzyskaj swój portfel Bitkit używając mnemonika. Ustaw nowy kod PIN po zakończeniu odzyskiwania portfela."; "security__pin_forgot_reset" = "Reset (wymaga mnemonika)"; "security__pin_send" = "Proszę podaj swój kod PIN aby potwierdzić i wysłać płatność."; +"security__pin_allowance" = "Proszę podaj swój kod PIN aby potwierdzić tę zmianę przydziału."; "security__pin_send_title" = "Wprowadź kod PIN"; "security__pin_use_biometrics" = "Użyj {biometricsName}"; "security__bio" = "Biometria"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index b766ae0f9..73018ee91 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -655,6 +655,7 @@ "security__pin_forgot_text" = "Esqueceu seu PIN? Redefina e recupere sua carteira Bitkit com a sua frase de recuperação. Defina um novo PIN após concluir a recuperação."; "security__pin_forgot_reset" = "Restaurar (Requer a Frase de Recuperação)"; "security__pin_send" = "Digite seu código PIN para confirmar e enviar esse pagamento."; +"security__pin_allowance" = "Digite seu código PIN para confirmar esta alteração da pré-aprovação."; "security__pin_send_title" = "Digitar o código PIN"; "security__pin_use_biometrics" = "Usar {biometricsName}"; "security__bio" = "Biometria"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 29a688eb5..164fcff19 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -590,6 +590,7 @@ "security__pin_forgot_text" = "Esqueceu seu PIN? Redefina e recupere sua carteira Bitkit com a sua frase de recuperação. Defina um novo PIN após concluir a recuperação."; "security__pin_forgot_reset" = "Restaurar (Requer a Frase de Recuperação)"; "security__pin_send" = "Digite seu código PIN para confirmar e enviar esse pagamento."; +"security__pin_allowance" = "Digite seu código PIN para confirmar esta alteração da pré-aprovação."; "security__pin_send_title" = "Digitar o código PIN"; "security__pin_use_biometrics" = "Usar {biometricsName}"; "security__bio" = "Biometria"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index db4b22908..f4cb13072 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -650,6 +650,7 @@ "security__pin_forgot_text" = "Забыли свой PIN-код? Сбросьте и восстановите свой кошелёк Bitkit с помощью фразы восстановления. Установите новый PIN-код после завершения восстановления."; "security__pin_forgot_reset" = "Сбросить (требуется Фраза Восстановления)"; "security__pin_send" = "Введите PIN-код, чтобы подтвердить и отправить этот платеж."; +"security__pin_allowance" = "Введите PIN-код, чтобы подтвердить это изменение лимита автооплаты."; "security__pin_send_title" = "Введите PIN-код"; "security__pin_use_biometrics" = "Использовать {biometricsName}"; "security__bio" = "Биометрия"; diff --git a/Bitkit/Views/Security/PaymentAuthorization.swift b/Bitkit/Views/Security/PaymentAuthorization.swift index 811a5619d..a9ba9b660 100644 --- a/Bitkit/Views/Security/PaymentAuthorization.swift +++ b/Bitkit/Views/Security/PaymentAuthorization.swift @@ -4,10 +4,16 @@ import SwiftUI @MainActor @Observable final class PaymentAuthorization { + /// What the PIN confirms, since a check that sends nothing must not say it sends a payment. + let pinMessage: String fileprivate(set) var isAskingForPin = false fileprivate(set) var biometricErrorMessage: String? @ObservationIgnored private var pinContinuation: CheckedContinuation? + init(pinMessage: String = t("security__pin_send")) { + self.pinMessage = pinMessage + } + func authorize(settings: SettingsViewModel) async -> Bool { guard settings.requirePinForPayments && settings.pinEnabled else { return true } if settings.useBiometrics && BiometricAuth.isAvailable { @@ -52,6 +58,7 @@ private struct PaymentAuthorizationModifier: ViewModifier { } )) { SendPinScreen( + message: authorization.pinMessage, onCancel: { authorization.resolvePin(false) }, onPinVerified: { authorization.resolvePin(true) } ) diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift index e3f370873..20c2922e0 100644 --- a/Bitkit/Views/Subscriptions/AllowancesView.swift +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -381,7 +381,7 @@ struct SetAllowanceView: View { @EnvironmentObject private var currency: CurrencyViewModel @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances - @State private var authorization = PaymentAuthorization() + @State private var authorization = PaymentAuthorization(pinMessage: t("security__pin_allowance")) let contact: PubkyContact let onBack: () -> Void @@ -606,7 +606,7 @@ struct AllowanceReviewView: View { @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances - @State private var authorization = PaymentAuthorization() + @State private var authorization = PaymentAuthorization(pinMessage: t("security__pin_allowance")) let entry: PaykitAllowanceEntry @@ -696,7 +696,7 @@ struct AllowanceDetailView: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var settings: SettingsViewModel @Environment(PaykitAllowanceManager.self) private var allowances - @State private var authorization = PaymentAuthorization() + @State private var authorization = PaymentAuthorization(pinMessage: t("security__pin_allowance")) let entry: PaykitAllowanceEntry diff --git a/Bitkit/Views/Wallets/Send/SendPinScreen.swift b/Bitkit/Views/Wallets/Send/SendPinScreen.swift index aa7dca1ba..8726f9ba7 100644 --- a/Bitkit/Views/Wallets/Send/SendPinScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPinScreen.swift @@ -7,6 +7,7 @@ struct SendPinScreen: View { @EnvironmentObject private var sheets: SheetViewModel @EnvironmentObject private var wallet: WalletViewModel + var message: String = t("security__pin_send") let onCancel: () -> Void let onPinVerified: () -> Void @@ -21,7 +22,7 @@ struct SendPinScreen: View { .padding(.horizontal, 16) VStack(spacing: 0) { - BodyMText(t("security__pin_send")) + BodyMText(message) .frame(maxWidth: .infinity, alignment: .leading) .padding(.bottom, 32) diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml index 97b0ea7f1..35ed1cf53 100644 --- a/journeys/allowances/pin-gates-allowance-changes.xml +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -16,7 +16,7 @@ On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (id "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month Tap Save Allowance (id "AllowanceSave") - Verify the PIN pad shows over the sheet, then press back + Verify the PIN pad shows over the sheet and asks to confirm an allowance change, not a payment, then press back Verify the Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no "Allowance Added" notification Tap Save Allowance again and enter the PIN Verify the sheet dismisses and the payer's row reads "Waiting for an answer" From cc24f0e29fafa1c31852aafa8ac7a138ae61a273 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:12:49 +0100 Subject: [PATCH 07/28] chore: add changelog fragment for allowance follow-ups --- changelog.d/next/1141.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1141.fixed.md diff --git a/changelog.d/next/1141.fixed.md b/changelog.d/next/1141.fixed.md new file mode 100644 index 000000000..002c7d369 --- /dev/null +++ b/changelog.d/next/1141.fixed.md @@ -0,0 +1 @@ +Allowances now load their contacts right after sign-in, announce every allowance accepted automatically, keep Auto-paid totals on busy wallets, and leave requests they can't pay over Lightning for manual payment. From 26074ba8768a516f9bcb462f9e65da5c0a0a0030 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:24:51 +0100 Subject: [PATCH 08/28] fix: release a failed payment's list only after its outcome is saved When the journal can't save an attempt's outcome, the entry stays open and a later settlement or recovery acts on it once, instead of releasing the list now and again after a relaunch. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 34 ++++++++++++------- .../PaykitAllowanceExecutorTests.swift | 18 ++++++++-- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 8527276f5..590c530b4 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -513,7 +513,8 @@ actor PaykitAllowanceExecutor { } /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, - /// so a settlement event and recovery never both raise Payment Executed. + /// so a settlement event and recovery never both raise Payment Executed. Returns nil when the move can't be saved: + /// the entry stays open, so a later settlement or recovery acts on it once instead of twice. @discardableResult private func applyJournalOutcome( attemptId: String, @@ -523,19 +524,26 @@ actor PaykitAllowanceExecutor { ) -> PaykitAllowanceLocalState.JournalEntry? { var entry: PaykitAllowanceLocalState.JournalEntry? var settledEntry: PaykitAllowanceLocalState.JournalEntry? - updateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), - !onlyIfOpen || !state.journal[index].stage.isTerminal - else { return } - entry = state.journal[index] - if outcome == .succeeded, state.journal[index].stage != .succeeded { - settledEntry = state.journal[index] - } - switch outcome { - case .succeeded: state.journal[index].stage = .succeeded - case .failed: state.journal[index].stage = .failed - case .unknown: state.journal[index].stage = .unknown + do { + var state = try store.load(identity: identity) + if let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), + !onlyIfOpen || !state.journal[index].stage.isTerminal + { + entry = state.journal[index] + if outcome == .succeeded, state.journal[index].stage != .succeeded { + settledEntry = state.journal[index] + } + switch outcome { + case .succeeded: state.journal[index].stage = .succeeded + case .failed: state.journal[index].stage = .failed + case .unknown: state.journal[index].stage = .unknown + } + try store.save(state, identity: identity) } + } catch { + Logger.warn("Failed to save an allowance attempt's outcome: \(error)", context: "PaykitAllowance") + entry = nil + settledEntry = nil } Self.eventSubject.send(.ledgerChanged) if let entry = settledEntry, entry.isAutomatic, let paymentHash = entry.paymentHash { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 41b5db387..39c838e95 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -773,8 +773,18 @@ final class PaykitAllowanceExecutorTests: XCTestCase { let listOutcomes = await harness.payer.paymentListOutcomes XCTAssertEqual(listOutcomes, [.uncertain], "A claim still saved in the journal must not be released twice") - let released = await harness.payer.releasedLists.map(\.version) - XCTAssertEqual(released.count, 1, "Recording the failure releases the list once, from the journal") + let releasedWhileSavesFail = await harness.payer.releasedLists + XCTAssertEqual(releasedWhileSavesFail, [], "A failure that can't be saved leaves the release to the recovery that saves it") + + harness.store.restoreSaves() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: AllowanceSdkMock.automaticAttemptId, status: .failed)]), + ])) + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + let released = await harness.payer.releasedLists + XCTAssertEqual(released.count, 1, "Recovery releases the list once, from the journal") } func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { @@ -1629,6 +1639,10 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen lock.withLock { savesFail = true } } + func restoreSaves() { + lock.withLock { savesFail = false } + } + func seed(_ state: PaykitAllowanceLocalState, identity: String) { lock.withLock { states[identity] = state } } From 2ac0e56e4949f06ef70f2a6c09b22f854d8036c0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:24:53 +0100 Subject: [PATCH 09/28] fix: count auto-paid history for the signed-in identity only The accounting history is scoped by the local identity; payments of another identity no longer show as Auto-paid, and a failed read keeps the labels and totals already shown. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 16 +++++++++++++--- Bitkit/Services/PaykitAllowanceManager.swift | 2 +- BitkitTests/PaykitAllowanceExecutorTests.swift | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 590c530b4..4b89f2f0f 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -566,9 +566,19 @@ actor PaykitAllowanceExecutor { } /// Read from the SDK ledger, which keeps every attempt, rather than the journal, which keeps only the newest ones. - func succeededAutomaticPayments() async -> [AutomaticPayment] { - guard let state = try? await sdk.allowanceAccountingState() else { return [] } - return state.history.occurrences.flatMap { occurrence in + /// Nil when the ledger can't be read, so the caller keeps what it showed. + func succeededAutomaticPayments(identity: String) async -> [AutomaticPayment]? { + let state: Paykit.AllowanceAccountingState? + do { + state = try await sdk.allowanceAccountingState() + } catch { + Logger.warn("Failed to read allowance accounting for automatic payments: \(error)", context: "PaykitAllowance") + return nil + } + guard let state else { return [] } + return state.history.occurrences.filter { + PubkyPublicKeyFormat.matches($0.key.request.localPublicKey, identity) + }.flatMap { occurrence in occurrence.attempts.compactMap { attempt in guard attempt.mode == .automatic, attempt.status == .succeeded, let allowanceId = attempt.allowanceId, let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value()) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index e0eae5495..010aae520 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -160,7 +160,7 @@ final class PaykitAllowanceManager { Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance") } localState = await executor.localState(identity: identity) - let paid = await executor.succeededAutomaticPayments() + guard let paid = await executor.succeededAutomaticPayments(identity: identity) else { return } autoPaidRequestIds = Set(paid.map(\.requestId)) autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, payment in totals[PaykitAllowance.scopedId(counterparty: payment.requestId.counterparty, allowanceId: payment.allowanceId), default: 0] += diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 39c838e95..e5293aa90 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1079,6 +1079,20 @@ final class PaykitAllowanceExecutorTests: XCTestCase { } let notPaid = try [ Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + Paykit.PaymentOccurrenceRecord( + key: Paykit.PaymentOccurrenceKey( + request: Paykit.PaymentAccountingScope( + localPublicKey: Fixtures.otherCounterpartyKey, + counterparty: Fixtures.counterpartyKey, + paymentRequestId: "req-other-identity" + ), + billingPeriod: nil + ), + disposition: .automatic, + allowanceId: Fixtures.walletAllowanceId, + associationRevision: 1, + attempts: [Fixtures.attemptRecord(id: "other-identity", status: .succeeded)] + ), Fixtures.occurrence( requestId: "req-manual", attempts: [Fixtures.attemptRecord(id: "manual", mode: .manual, allowanceId: nil, status: .succeeded)], From e641ef21f592df89754ff67faf2037c9a3b289f1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:24:53 +0100 Subject: [PATCH 10/28] fix: start allowances again when a session change cuts the start short The start marked the identity as started before it ran, so a deletion or session change that stopped it left contacts unloaded until sign-out. --- Bitkit/AppScene.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index ae6af5c90..3040db14b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -2016,7 +2016,13 @@ struct AppScene: View { Task { await paykitAllowanceManager.activate(identity: pk) await handlePendingPaykitSubscriptionNotification() - guard pubkyProfile.automaticPaykitSession == session else { return } + guard pubkyProfile.automaticPaykitSession == session else { + // A deletion or session change cut this start short; let the session's next appearance run it again. + if activatedAutomaticPaykitIdentity == pk { + activatedAutomaticPaykitIdentity = nil + } + return + } try? await contactsManager.loadContacts(for: pk) if !PaykitFeatureFlags.isUIEnabled, wallet.walletExists == true { await retryPendingPaykitEndpointRemoval() From 29ecfc29095676d847a6f5c0306957db5827ff93 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:24:53 +0100 Subject: [PATCH 11/28] fix: name each contact once in a combined allowance added notice Also matches the shared allowance journeys' wording to the Android twin. --- Bitkit/AppScene.swift | 15 ++++++++------- journeys/allowances/end-stops-auto-pay.xml | 2 +- journeys/allowances/monthly-cap-reached.xml | 2 +- .../allowances/pin-gates-allowance-changes.xml | 2 +- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 3040db14b..cee797129 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -2040,14 +2040,15 @@ struct AppScene: View { /// One announcement for all offers accepted in a refresh, since each fallback toast would replace the one before it. private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) { - guard let first = offers.first else { return } + var names: [String] = [] + for offer in offers where !names.contains(contactName(offer.counterparty)) { + names.append(contactName(offer.counterparty)) + } + guard let first = names.first else { return } let title = t("subscriptions__allowance_offer_accepted_title") - let description = offers.count == 1 - ? t("subscriptions__allowance_offer_accepted_description", variables: ["name": contactName(first.counterparty)]) - : t( - "subscriptions__allowance_offers_accepted_description", - variables: ["names": ListFormatter.localizedString(byJoining: offers.map { contactName($0.counterparty) })] - ) + let description = names.count == 1 + ? t("subscriptions__allowance_offer_accepted_description", variables: ["name": first]) + : t("subscriptions__allowance_offers_accepted_description", variables: ["names": ListFormatter.localizedString(byJoining: names)]) PaykitAllowanceNotifier.post(title: title, body: description, fallback: { app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast") }) diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml index a37018526..582b67264 100644 --- a/journeys/allowances/end-stops-auto-pay.xml +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -16,7 +16,7 @@ On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (id "PaymentRequestsBell") as an ordinary Payment Request Dismiss that request On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" - Set a fresh $5 a payment, $50 a month allowance between the same two instances and let the payee's wallet accept it, per set-and-accept.xml + Set a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml; the payee's wallet accepts it by itself On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end Verify the payee's row reads "Ended" On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml index 087d9fa44..557481000 100644 --- a/journeys/allowances/monthly-cap-reached.xml +++ b/journeys/allowances/monthly-cap-reached.xml @@ -8,7 +8,7 @@ Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted - On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"), and wait for the payee's wallet to accept it by itself + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"); the payee's wallet accepts it by itself Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml index 35ed1cf53..bb96fc1bb 100644 --- a/journeys/allowances/pin-gates-allowance-changes.xml +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -16,7 +16,7 @@ On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (id "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month Tap Save Allowance (id "AllowanceSave") - Verify the PIN pad shows over the sheet and asks to confirm an allowance change, not a payment, then press back + Verify the PIN pad shows over the sheet and reads "Please enter your PIN code to confirm this allowance change.", then press back Verify the Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no "Allowance Added" notification Tap Save Allowance again and enter the PIN Verify the sheet dismisses and the payer's row reads "Waiting for an answer" From 99b02d1e24916dada0340420c88dc8e565e8297e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 01:35:32 +0100 Subject: [PATCH 12/28] fix: keep allowance labels and open attempts across a wallet restore The allowance state (USD limit labels and the attempt journal recovery verifies from) lived only in the keychain, so a restored wallet lost its labels and left open attempts unverifiable. It is now part of the Paykit payment state backup, in a format both apps share, and changes to it schedule a backup. --- .../Models/PaykitAllowanceStateBackup.swift | 133 ++++++++++++++++++ Bitkit/Models/PaykitPaymentStateBackup.swift | 2 + Bitkit/Services/BackupService.swift | 1 + Bitkit/Services/PaykitAllowanceExecutor.swift | 21 +++ .../Services/PaykitPaymentProofService.swift | 6 +- BitkitTests/PaykitAllowanceTests.swift | 53 +++++++ 6 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 Bitkit/Models/PaykitAllowanceStateBackup.swift diff --git a/Bitkit/Models/PaykitAllowanceStateBackup.swift b/Bitkit/Models/PaykitAllowanceStateBackup.swift new file mode 100644 index 000000000..8aab8d643 --- /dev/null +++ b/Bitkit/Models/PaykitAllowanceStateBackup.swift @@ -0,0 +1,133 @@ +import Foundation + +/// One identity's local allowance state in the wallet backup, in the format both apps read: the USD labels of each +/// grant and the attempt journal restart recovery verifies from. Timestamps are RFC 3339, USD amounts decimal strings. +struct PaykitAllowanceStateBackup: Codable { + struct Limits: Codable { + let perPaymentUsd: String + let monthlyUsd: String + let perPaymentSats: UInt64 + let monthlySats: UInt64 + } + + struct Group: Codable { + let id: String + let counterparty: String + let limits: Limits + let allowanceIds: [String] + let createdAt: String + } + + struct JournalEntry: Codable { + let attemptId: String + let isAutomatic: Bool + let requestId: PaykitPaymentStateBackup.RequestID + let allowanceId: String? + let amountSats: UInt64 + let paymentEndpointIdentifier: String + let paymentHash: String? + let onchainAddress: String? + let transactionId: String? + let stage: PaykitAllowanceLocalState.Stage + let createdAt: String + let paymentListVersion: UInt64? + let previousPaymentListVersion: UInt64? + } + + let groups: [Group] + let journal: [JournalEntry] + let presentedProposalIds: Set + let unannouncedOfferIds: Set + let notifiedRequestIds: Set + let lastTrustedTime: String? + + init(_ state: PaykitAllowanceLocalState) { + groups = state.groups.map { group in + Group( + id: group.id, + counterparty: group.counterparty, + limits: Limits( + perPaymentUsd: "\(group.limits.perPaymentUsd)", + monthlyUsd: "\(group.limits.monthlyUsd)", + perPaymentSats: group.limits.perPaymentSats, + monthlySats: group.limits.monthlySats + ), + allowanceIds: group.allowanceIds, + createdAt: PaykitAllowanceTime.format(group.createdAt) + ) + } + journal = state.journal.map { entry in + JournalEntry( + attemptId: entry.attemptId, + isAutomatic: entry.isAutomatic, + requestId: PaykitPaymentStateBackup.RequestID(entry.requestId, billingPeriod: nil), + allowanceId: entry.allowanceId, + amountSats: entry.amountSats, + paymentEndpointIdentifier: entry.paymentEndpointIdentifier, + paymentHash: entry.paymentHash, + onchainAddress: entry.onchainAddress, + transactionId: entry.transactionId, + stage: entry.stage, + createdAt: PaykitAllowanceTime.format(entry.createdAt), + paymentListVersion: entry.paymentListVersion, + previousPaymentListVersion: entry.previousPaymentListVersion + ) + } + presentedProposalIds = state.presentedProposalIds + unannouncedOfferIds = state.unannouncedOfferIds + notifiedRequestIds = state.notifiedRequestIds + lastTrustedTime = state.lastTrustedTime.map(PaykitAllowanceTime.format) + } + + func restored() throws -> PaykitAllowanceLocalState { + try PaykitAllowanceLocalState( + groups: groups.map { group in + guard let perPaymentUsd = Decimal(string: group.limits.perPaymentUsd, locale: Locale(identifier: "en_US_POSIX")), + let monthlyUsd = Decimal(string: group.limits.monthlyUsd, locale: Locale(identifier: "en_US_POSIX")) + else { throw Self.invalidBackup("Invalid allowance limits") } + return try PaykitAllowanceLocalState.Group( + id: group.id, + counterparty: group.counterparty, + limits: PaykitAllowanceLimits( + perPaymentUsd: perPaymentUsd, + monthlyUsd: monthlyUsd, + perPaymentSats: group.limits.perPaymentSats, + monthlySats: group.limits.monthlySats + ), + allowanceIds: group.allowanceIds, + createdAt: Self.date(group.createdAt) + ) + }, + journal: journal.map { entry in + try PaykitAllowanceLocalState.JournalEntry( + attemptId: entry.attemptId, + isAutomatic: entry.isAutomatic, + requestId: entry.requestId.restored(billingPeriod: nil), + allowanceId: entry.allowanceId, + amountSats: entry.amountSats, + paymentEndpointIdentifier: entry.paymentEndpointIdentifier, + paymentHash: entry.paymentHash, + onchainAddress: entry.onchainAddress, + transactionId: entry.transactionId, + stage: entry.stage, + createdAt: Self.date(entry.createdAt), + paymentListVersion: entry.paymentListVersion, + previousPaymentListVersion: entry.previousPaymentListVersion + ) + }, + presentedProposalIds: presentedProposalIds, + unannouncedOfferIds: unannouncedOfferIds, + notifiedRequestIds: notifiedRequestIds, + lastTrustedTime: lastTrustedTime.map(Self.date) + ) + } + + private static func date(_ value: String) throws -> Date { + guard let date = PaykitAllowanceTime.parse(value) else { throw invalidBackup("Invalid allowance timestamp") } + return date + } + + private static func invalidBackup(_ message: String) -> NSError { + NSError(domain: "BackupService", code: -1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 48763f950..ca3b3e063 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -8,6 +8,8 @@ struct PaykitPaymentStateBackup: Codable { var activeOnchainAttempt: ActiveOnchainAttempt? = nil var usdt: PaykitUsdtStateBackup? = nil var acceptedOneTimeRequests: [String: [RequestID]]? = nil + /// Local allowance state by identity, so a restored wallet keeps its USD labels and can still verify open attempts. + var allowances: [String: PaykitAllowanceStateBackup]? = nil struct RequestID: Codable { let paymentRequestId: String diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index f9e40f1e4..43d924480 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -1083,6 +1083,7 @@ final class WalletBackupChangeTracker { PrivatePaykitService.walletBackupDataChangedPublisher, PaykitSubscriptionStateStore.walletBackupDataChangedPublisher, PaykitPaymentRequestIdStore.walletBackupDataChangedPublisher, + PaykitAllowanceKeychainStore.walletBackupDataChangedPublisher, PaykitPaymentProofService.proofStateChangedPublisher, OnchainSendAttemptStore.walletBackupDataChangedPublisher, UsdtWalletManager.walletBackupDataChangedPublisher, diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 4b89f2f0f..4b4b2afcf 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -106,14 +106,35 @@ protocol PaykitAllowanceStoring: Sendable { struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { private typealias Stored = [String: PaykitAllowanceLocalState] + private static let backupChanged = PassthroughSubject() + static var walletBackupDataChangedPublisher: AnyPublisher { + backupChanged.eraseToAnyPublisher() + } + func load(identity: String) throws -> PaykitAllowanceLocalState { try loadAll()[identity] ?? PaykitAllowanceLocalState() } func save(_ state: PaykitAllowanceLocalState, identity: String) throws { var all = try loadAll() + let previous = all[identity] all[identity] = state try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + // The trusted-time watermark moves on every eligibility check; only a change to the rest needs a new backup. + var unchanged = state + unchanged.lastTrustedTime = previous?.lastTrustedTime + if unchanged != previous { + Self.backupChanged.send() + } + } + + func backupSnapshot() throws -> [String: PaykitAllowanceStateBackup] { + try loadAll().mapValues(PaykitAllowanceStateBackup.init) + } + + func restoreBackup(_ snapshot: [String: PaykitAllowanceStateBackup]) throws { + let all = try snapshot.mapValues { try $0.restored() } + try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) } private func loadAll() throws -> Stored { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 2323ef787..25c1d5e8c 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -293,7 +293,8 @@ actor PaykitPaymentProofService { return try PaykitPaymentStateBackup(subscriptions: PaykitSubscriptionStateStore().backupSnapshot(), pendingProofs: proofs.map(PaykitPaymentStateBackup.Proof.init), activeOnchainAttempt: active, acceptedOneTimeRequests: PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) - .backupSnapshot()) + .backupSnapshot(), + allowances: PaykitAllowanceKeychainStore().backupSnapshot()) } } @@ -305,6 +306,9 @@ actor PaykitPaymentProofService { try await mutationLock.withLock { try await persist(restored?.1 ?? proofs) } try PaykitSubscriptionStateStore().restoreBackup(state.subscriptions) try PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).restoreBackup(state.acceptedOneTimeRequests ?? [:]) + if let allowances = state.allowances { + try PaykitAllowanceKeychainStore().restoreBackup(allowances) + } } func restoreBackup(_ proofs: [PaykitPaymentStateBackup.Proof]) async throws { diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 3b2742282..06aa998bb 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -9,6 +9,59 @@ final class PaykitAllowanceTests: XCTestCase { // MARK: Terms and records + func testAllowanceStateBackupKeepsLabelsAndJournalInTheSharedFormat() throws { + let requestId = PaykitPaymentRequest.ID(paymentRequestId: "req-1", counterparty: Fixtures.counterpartyKey) + let state = try PaykitAllowanceLocalState( + groups: [.init( + id: "group-1", + counterparty: Fixtures.counterpartyKey, + limits: PaykitAllowanceLimits( + perPaymentUsd: XCTUnwrap(Decimal(string: "4.99")), + monthlyUsd: 50, + perPaymentSats: 4990, + monthlySats: 50000 + ), + allowanceIds: [Fixtures.walletAllowanceId], + createdAt: Fixtures.now + )], + journal: [.init( + attemptId: "attempt-1", + isAutomatic: true, + requestId: requestId, + allowanceId: Fixtures.walletAllowanceId, + amountSats: 1000, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: String(repeating: "a", count: 64), + onchainAddress: nil, + transactionId: nil, + stage: .sent, + createdAt: Fixtures.now, + paymentListVersion: 7, + previousPaymentListVersion: 6 + )], + presentedProposalIds: ["presented"], + unannouncedOfferIds: ["unannounced"], + notifiedRequestIds: ["notified"], + lastTrustedTime: Fixtures.now + ) + + let data = try JSONEncoder().encode(PaykitAllowanceStateBackup(state)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let group = try XCTUnwrap((json["groups"] as? [[String: Any]])?.first) + let limits = try XCTUnwrap(group["limits"] as? [String: Any]) + XCTAssertEqual(limits["perPaymentUsd"] as? String, "4.99") + XCTAssertEqual(limits["monthlySats"] as? Int, 50000) + XCTAssertEqual(group["createdAt"] as? String, "2026-09-24T12:00:00.000Z") + let entry = try XCTUnwrap((json["journal"] as? [[String: Any]])?.first) + XCTAssertEqual(entry["stage"] as? String, "sent") + XCTAssertEqual(entry["paymentListVersion"] as? Int, 7) + XCTAssertEqual((entry["requestId"] as? [String: Any])?["paymentRequestId"] as? String, "req-1") + XCTAssertEqual(json["lastTrustedTime"] as? String, "2026-09-24T12:00:00.000Z") + + let restored = try JSONDecoder().decode(PaykitAllowanceStateBackup.self, from: data).restored() + XCTAssertEqual(restored, state) + } + func testTermsCarryPerPaymentRangeAnchoredUtcMonthAndAllowlist() throws { let monthAnchor = PaykitAllowanceTime.monthStart(containing: Fixtures.now) let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] From 48f34cf7a0a08031cb5afa2816a9884f3b8f1352 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 03:45:39 +0100 Subject: [PATCH 13/28] docs: note how allowance journey requests are priced on each platform --- journeys/allowances/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 534aa5a3a..5e896b9a7 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -23,7 +23,10 @@ UI snapshot cannot see. The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests -created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +created from the Payments tab of the payee, priced in bitcoin. On Android, enter the amount in sats: +a request entered in the fiat unit is priced in USD, and allowances pay only bitcoin-priced requests, so it +stays manual. iOS converts a dollar entry to a bitcoin amount when it creates the request, so the fiat unit +works there. Dollar amounts on screen are converted at the current rate, so a sats amount next to them will differ between runs. ## Reference evidence From d0b9084a5b849a52a7bf903186ede343d3fc5df3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 03:55:28 +0100 Subject: [PATCH 14/28] fix: keep a bad allowance backup entry from failing the wallet backup Allowance state rides in the wallet backup, so reading, restoring or capturing it is now best-effort: an entry this version can't read is skipped and logged, and the rest of the wallet backup and restore go ahead. --- .../Models/PaykitAllowanceStateBackup.swift | 31 +++++++++++++++++++ Bitkit/Models/PaykitPaymentStateBackup.swift | 2 +- Bitkit/Services/PaykitAllowanceExecutor.swift | 28 ++++++++++++++--- .../Services/PaykitPaymentProofService.swift | 2 +- BitkitTests/PaykitAllowanceTests.swift | 17 ++++++++++ 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/Bitkit/Models/PaykitAllowanceStateBackup.swift b/Bitkit/Models/PaykitAllowanceStateBackup.swift index 8aab8d643..a46c89548 100644 --- a/Bitkit/Models/PaykitAllowanceStateBackup.swift +++ b/Bitkit/Models/PaykitAllowanceStateBackup.swift @@ -1,5 +1,36 @@ import Foundation +/// The allowance states of a wallet backup by identity. An entry this version can't read is dropped instead of failing the +/// rest of the wallet backup it travels in. +struct PaykitAllowanceStateBackups: Codable { + let byIdentity: [String: PaykitAllowanceStateBackup] + + init(_ byIdentity: [String: PaykitAllowanceStateBackup]) { + self.byIdentity = byIdentity + } + + init(from decoder: Decoder) throws { + let entries = (try? decoder.singleValueContainer().decode([String: Lenient].self)) ?? [:] + byIdentity = entries.compactMapValues(\.value) + if byIdentity.count != entries.count { + Logger.warn("Skipped \(entries.count - byIdentity.count) unreadable allowance states in a backup", context: "PaykitAllowance") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(byIdentity) + } + + private struct Lenient: Decodable { + let value: PaykitAllowanceStateBackup? + + init(from decoder: Decoder) throws { + value = try? PaykitAllowanceStateBackup(from: decoder) + } + } +} + /// One identity's local allowance state in the wallet backup, in the format both apps read: the USD labels of each /// grant and the attempt journal restart recovery verifies from. Timestamps are RFC 3339, USD amounts decimal strings. struct PaykitAllowanceStateBackup: Codable { diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index ca3b3e063..9e631a952 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -9,7 +9,7 @@ struct PaykitPaymentStateBackup: Codable { var usdt: PaykitUsdtStateBackup? = nil var acceptedOneTimeRequests: [String: [RequestID]]? = nil /// Local allowance state by identity, so a restored wallet keeps its USD labels and can still verify open attempts. - var allowances: [String: PaykitAllowanceStateBackup]? = nil + var allowances: PaykitAllowanceStateBackups? = nil struct RequestID: Codable { let paymentRequestId: String diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 4b4b2afcf..e971e0c72 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -128,13 +128,31 @@ struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { } } - func backupSnapshot() throws -> [String: PaykitAllowanceStateBackup] { - try loadAll().mapValues(PaykitAllowanceStateBackup.init) + /// Nil when the state can't be read, so the rest of the wallet backup still goes out. + func backupSnapshot() -> PaykitAllowanceStateBackups? { + do { + return try PaykitAllowanceStateBackups(loadAll().mapValues(PaykitAllowanceStateBackup.init)) + } catch { + Logger.warn("Left allowance state out of the wallet backup: \(error)", context: "PaykitAllowance") + return nil + } } - func restoreBackup(_ snapshot: [String: PaykitAllowanceStateBackup]) throws { - let all = try snapshot.mapValues { try $0.restored() } - try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + /// Restores each identity's state it can read; a failure here never stops the rest of the wallet restore. + func restoreBackup(_ snapshot: PaykitAllowanceStateBackups) { + var all: Stored = [:] + for (identity, backup) in snapshot.byIdentity { + do { + all[identity] = try backup.restored() + } catch { + Logger.warn("Skipped an unreadable allowance state in a backup: \(error)", context: "PaykitAllowance") + } + } + do { + try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + } catch { + Logger.warn("Failed to restore allowance state: \(error)", context: "PaykitAllowance") + } } private func loadAll() throws -> Stored { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 25c1d5e8c..e6dad6685 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -307,7 +307,7 @@ actor PaykitPaymentProofService { try PaykitSubscriptionStateStore().restoreBackup(state.subscriptions) try PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests).restoreBackup(state.acceptedOneTimeRequests ?? [:]) if let allowances = state.allowances { - try PaykitAllowanceKeychainStore().restoreBackup(allowances) + PaykitAllowanceKeychainStore().restoreBackup(allowances) } } diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 06aa998bb..2e08447d0 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -62,6 +62,23 @@ final class PaykitAllowanceTests: XCTestCase { XCTAssertEqual(restored, state) } + func testAnUnreadableAllowanceStateLeavesTheRestOfTheBackupReadable() throws { + let json = Data(#""" + { + "subscriptions": {}, + "pendingProofs": [], + "allowances": { + "pubky-good": {"groups": [], "journal": [], "presentedProposalIds": [], "unannouncedOfferIds": [], "notifiedRequestIds": []}, + "pubky-bad": {"groups": [], "journal": [{"stage": "teleported"}]} + } + } + """#.utf8) + + let backup = try JSONDecoder().decode(PaykitPaymentStateBackup.self, from: json) + + XCTAssertEqual(backup.allowances.map { Array($0.byIdentity.keys) }, ["pubky-good"]) + } + func testTermsCarryPerPaymentRangeAnchoredUtcMonthAndAllowlist() throws { let monthAnchor = PaykitAllowanceTime.monthStart(containing: Fixtures.now) let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] From e4e6b8577004bd88d94d8ea38225c43ea0a5b96c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 03:56:00 +0100 Subject: [PATCH 15/28] chore: cover the restore fix in the allowance changelog entry --- changelog.d/next/1141.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1141.fixed.md b/changelog.d/next/1141.fixed.md index 002c7d369..dc01fea0d 100644 --- a/changelog.d/next/1141.fixed.md +++ b/changelog.d/next/1141.fixed.md @@ -1 +1 @@ -Allowances now load their contacts right after sign-in, announce every allowance accepted automatically, keep Auto-paid totals on busy wallets, and leave requests they can't pay over Lightning for manual payment. +Allowances now load their contacts right after sign-in, announce every allowance accepted automatically, keep their limits and Auto-paid totals on busy wallets and across a wallet restore, and leave requests they can't pay over Lightning for manual payment. From aa2b648c29bd01e73c3197d4f8bb9b5494ec4cf9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 04:00:19 +0100 Subject: [PATCH 16/28] fix: restart allowances for a session that appeared mid-start and merge restored state A same-key session that appeared while a start waited now runs its own start. Restoring allowance state keeps local identities the backup lacks, backup identity keys are normalized as on Android, and the combined Allowance Added notice names each contact once by key. --- Bitkit/AppScene.swift | 9 ++++++--- Bitkit/Services/PaykitAllowanceExecutor.swift | 13 +++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index cee797129..38825f752 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -2021,6 +2021,8 @@ struct AppScene: View { if activatedAutomaticPaykitIdentity == pk { activatedAutomaticPaykitIdentity = nil } + // A new session for the same key may already have appeared while this one waited. + startAutomaticPaykitSession() return } try? await contactsManager.loadContacts(for: pk) @@ -2040,10 +2042,11 @@ struct AppScene: View { /// One announcement for all offers accepted in a refresh, since each fallback toast would replace the one before it. private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) { - var names: [String] = [] - for offer in offers where !names.contains(contactName(offer.counterparty)) { - names.append(contactName(offer.counterparty)) + var counterparties: [String] = [] + for offer in offers where !counterparties.contains(where: { PubkyPublicKeyFormat.matches($0, offer.counterparty) }) { + counterparties.append(offer.counterparty) } + let names = counterparties.map(contactName) guard let first = names.first else { return } let title = t("subscriptions__allowance_offer_accepted_title") let description = names.count == 1 diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index e971e0c72..7162d8868 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -131,19 +131,24 @@ struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { /// Nil when the state can't be read, so the rest of the wallet backup still goes out. func backupSnapshot() -> PaykitAllowanceStateBackups? { do { - return try PaykitAllowanceStateBackups(loadAll().mapValues(PaykitAllowanceStateBackup.init)) + var byIdentity: [String: PaykitAllowanceStateBackup] = [:] + for (identity, state) in try loadAll() { + byIdentity[PubkyPublicKeyFormat.normalized(identity) ?? identity] = PaykitAllowanceStateBackup(state) + } + return PaykitAllowanceStateBackups(byIdentity) } catch { Logger.warn("Left allowance state out of the wallet backup: \(error)", context: "PaykitAllowance") return nil } } - /// Restores each identity's state it can read; a failure here never stops the rest of the wallet restore. + /// Restores each identity's state it can read over the local one; identities the backup lacks keep theirs. A failure + /// here never stops the rest of the wallet restore. func restoreBackup(_ snapshot: PaykitAllowanceStateBackups) { - var all: Stored = [:] + var all = (try? loadAll()) ?? [:] for (identity, backup) in snapshot.byIdentity { do { - all[identity] = try backup.restored() + all[PubkyPublicKeyFormat.normalized(identity) ?? identity] = try backup.restored() } catch { Logger.warn("Skipped an unreadable allowance state in a backup: \(error)", context: "PaykitAllowance") } From e56f58afeecb2a2d8b9457911c588364cceee891 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 05:03:41 +0100 Subject: [PATCH 17/28] fix: write absent allowance backup values as null so android restores them Swift left an absent optional out of the allowance backup, and Android requires every key, so a manual payment's journal entry (no allowance) made an iOS wallet backup fail to restore on Android. Every optional is now written as an explicit null. --- .../Models/PaykitAllowanceStateBackup.swift | 28 +++++++++++++++++++ BitkitTests/PaykitAllowanceTests.swift | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/Bitkit/Models/PaykitAllowanceStateBackup.swift b/Bitkit/Models/PaykitAllowanceStateBackup.swift index a46c89548..74bb18233 100644 --- a/Bitkit/Models/PaykitAllowanceStateBackup.swift +++ b/Bitkit/Models/PaykitAllowanceStateBackup.swift @@ -63,6 +63,24 @@ struct PaykitAllowanceStateBackup: Codable { let createdAt: String let paymentListVersion: UInt64? let previousPaymentListVersion: UInt64? + + /// Android requires every key, so an absent optional is written as null rather than left out. + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(attemptId, forKey: .attemptId) + try container.encode(isAutomatic, forKey: .isAutomatic) + try container.encode(requestId, forKey: .requestId) + try container.encode(allowanceId, forKey: .allowanceId) + try container.encode(amountSats, forKey: .amountSats) + try container.encode(paymentEndpointIdentifier, forKey: .paymentEndpointIdentifier) + try container.encode(paymentHash, forKey: .paymentHash) + try container.encode(onchainAddress, forKey: .onchainAddress) + try container.encode(transactionId, forKey: .transactionId) + try container.encode(stage, forKey: .stage) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(paymentListVersion, forKey: .paymentListVersion) + try container.encode(previousPaymentListVersion, forKey: .previousPaymentListVersion) + } } let groups: [Group] @@ -72,6 +90,16 @@ struct PaykitAllowanceStateBackup: Codable { let notifiedRequestIds: Set let lastTrustedTime: String? + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(groups, forKey: .groups) + try container.encode(journal, forKey: .journal) + try container.encode(presentedProposalIds, forKey: .presentedProposalIds) + try container.encode(unannouncedOfferIds, forKey: .unannouncedOfferIds) + try container.encode(notifiedRequestIds, forKey: .notifiedRequestIds) + try container.encode(lastTrustedTime, forKey: .lastTrustedTime) + } + init(_ state: PaykitAllowanceLocalState) { groups = state.groups.map { group in Group( diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 2e08447d0..e63f1ffbc 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -57,11 +57,39 @@ final class PaykitAllowanceTests: XCTestCase { XCTAssertEqual(entry["paymentListVersion"] as? Int, 7) XCTAssertEqual((entry["requestId"] as? [String: Any])?["paymentRequestId"] as? String, "req-1") XCTAssertEqual(json["lastTrustedTime"] as? String, "2026-09-24T12:00:00.000Z") + XCTAssertTrue(entry["onchainAddress"] is NSNull, "Android requires every key, so an absent value is written as null") + XCTAssertTrue(entry["transactionId"] is NSNull) let restored = try JSONDecoder().decode(PaykitAllowanceStateBackup.self, from: data).restored() XCTAssertEqual(restored, state) } + func testAManualPaymentJournalEntryWritesItsMissingAllowanceAsNull() throws { + var state = PaykitAllowanceLocalState() + state.journal = [.init( + attemptId: "manual-1", + isAutomatic: false, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "req-1", counterparty: Fixtures.counterpartyKey), + allowanceId: nil, + amountSats: 1000, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: nil, + onchainAddress: nil, + transactionId: nil, + stage: .submitted, + createdAt: Fixtures.now + )] + + let data = try JSONEncoder().encode(PaykitAllowanceStateBackup(state)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let entry = try XCTUnwrap((json["journal"] as? [[String: Any]])?.first) + + for key in ["allowanceId", "paymentHash", "onchainAddress", "transactionId", "paymentListVersion", "previousPaymentListVersion"] { + XCTAssertTrue(entry[key] is NSNull, "\(key) is written as null for Android") + } + XCTAssertTrue(json["lastTrustedTime"] is NSNull) + } + func testAnUnreadableAllowanceStateLeavesTheRestOfTheBackupReadable() throws { let json = Data(#""" { From 27d167575e9d68345b7c57d521fd372afbd66b52 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 10:15:37 +0100 Subject: [PATCH 18/28] docs: add the restore keeps history allowance journey --- journeys/allowances/README.md | 1 + journeys/allowances/restore-keeps-history.xml | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 journeys/allowances/restore-keeps-history.xml diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 5e896b9a7..248b0bcb0 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -6,6 +6,7 @@ limit falls back to the normal Payment Request sheet, and either side ends it. T pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. The PIN journey checks that the payer's grant, accept and end ask for the payment PIN when the user requires one, while automatic payments never prompt. +The restore journey checks that a payer restored from its recovery phrase keeps its allowance history. ## Setup diff --git a/journeys/allowances/restore-keeps-history.xml b/journeys/allowances/restore-keeps-history.xml new file mode 100644 index 000000000..b6a447eea --- /dev/null +++ b/journeys/allowances/restore-keeps-history.xml @@ -0,0 +1,25 @@ + + + A payer restored from its recovery phrase keeps its allowance history: the allowances list, the + "Paid automatically" total and the "Auto-paid" tag come back from the wallet backup. Reset And + Restore wipes the keychain, where the allowance state lives, so a restore on the same instance + reads it back from the backup; no second install is needed. Requires the Active allowance from + set-and-accept.xml and the payer's notification permission granted. The journey wipes the payer + half way through, so do not run it against a wallet whose funds matter. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payer instance, open the menu, navigate to Settings, then the "Security" tab (id "Tab-security"), then tap "Back Up Your Money" (id "BackupWallet"), continue past the intro (id "BackupIntroViewContinue"), tap to reveal the phrase (id "TapToReveal") and record the phrase from the word grid (id "SeedContainer") + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (id "Tab-payments") and send the payer a one-time Payment Request for $2 with the note "Restore" + Switch to the payer instance, with Bitkit in the foreground, and verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted as a banner, or under Bitkit in Notification Center + On the payer instance, open the drawer menu, tap Subscriptions and tap the Allowances tab (id "Tab-allowances"), then tap the payee's row (id "AllowanceRow-<entryId>") and record PAID AUTOMATICALLY (id "AllowancePaidSoFar") + Open Settings, then the "Security" tab, then "Data Backups" (id "BackupSettings"), and wait on the list (id "BackupScrollView") until the "Boosts & Transfers" row reports a backup made after the payment — that row carries no id, so read its text + Open Settings, then the "Security" tab, then "Reset And Restore" (id "ResetAndRestore"), tap "Reset Wallet" and confirm with "Yes, Reset" in the alert — neither carries an id, so tap them by label + On the onboarding screen tap "Restore" (id "RestoreWallet"), then "MultipleDevices-button", enter the recorded words into the word inputs (id "Word-0" onwards) and tap Restore (id "RestoreButton") + Wait for the restore to finish and tap "Get Started" (id "GetStartedButton") + Open the drawer menu, tap Subscriptions and tap the Allowances tab + Verify the payee's row (id "AllowanceRow-<entryId>") is listed as Active + Tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") shows the total recorded before the reset, converted at the current rate + Close the sheet, tap the Payments tab and verify the "Restore" row (id "PaymentRequestRow-<paymentRequestId>-one-time") is listed with a subtitle ending in "· Auto-paid" + + From 100d7b2d6707de270fa6b28503ef5de8f6f94e32 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 10:27:02 +0100 Subject: [PATCH 19/28] fix: let the node settle a restored allowance attempt --- Bitkit/Services/PaykitAllowanceExecutor.swift | 13 ++++++++++-- .../PaykitAllowanceExecutorTests.swift | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 7162d8868..5def46599 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -514,8 +514,17 @@ actor PaykitAllowanceExecutor { } switch entry.stage { case .prepared, .submitted: - // The journal is written before the node call, so no payment left this wallet. - return .failed + // The live journal is written before the node call, but a restored snapshot may predate the send, + // so a node that knows the payment decides. + guard let paymentHash = entry.paymentHash else { return .failed } + switch await lightningLookup.status(paymentHash: paymentHash) { + case .succeeded: + return .succeeded + case .pending: + return nil + case .failed, .unknown: + return .failed + } case .succeeded: return .succeeded case .failed: diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index e5293aa90..1293dced2 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -647,6 +647,27 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 7, previousVersion: 6)]) } + func testRecoverySettlesARestoredSubmittedAttemptFromTheNode() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-restored", attempts: [Fixtures.attemptRecord(id: "restored", status: .submitted)]), + ])) + let captured = PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "restored", request: request, stage: .submitted, paymentHash: paidHash), + ]) + try harness.store.seed(PaykitAllowanceStateBackup(captured).restored(), identity: Fixtures.identityKey) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes.map(\.outcome), [.succeeded]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + } + func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() From 37b2ed737757b9215e74b9f448bf2cf1e31c27ca Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 10:33:43 +0100 Subject: [PATCH 20/28] fix: announce an accepted allowance offer once when its mark fails to save --- Bitkit/Services/PaykitAllowanceExecutor.swift | 6 +++ Bitkit/Services/PaykitAllowanceManager.swift | 13 +++++- .../PaykitAllowanceExecutorTests.swift | 40 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 5def46599..1b5d08c65 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -385,6 +385,12 @@ actor PaykitAllowanceExecutor { } } + func checkedUpdateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) -> Void) throws { + var state = try store.load(identity: identity) + change(&state) + try store.save(state, identity: identity) + } + func isHandling(_ requestId: PaykitPaymentRequest.ID) -> Bool { inFlightRequestIds.contains(requestId) } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 010aae520..1c647f7ab 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -69,6 +69,8 @@ final class PaykitAllowanceManager { @ObservationIgnored private var identity: String? @ObservationIgnored private var isProcessingRequests = false @ObservationIgnored private var isAcceptingOffers = false + /// Offers already returned for announcement, so a mark whose clear failed to save is not announced again on every poll. + @ObservationIgnored private var announcedOfferIds: Set = [] private var updateDepth = 0 /// Whether allowances are being refreshed or a pass is deciding requests, so a covered request may still change hands. @@ -277,7 +279,13 @@ final class PaykitAllowanceManager { for offer in offers { guard self.identity == identity, canContinue() else { break } let ids = offer.allowances.map(\.scopedId) - await executor.updateLocalState(identity: identity) { $0.unannouncedOfferIds.formUnion(ids) } + do { + // An offer accepted without its mark would never be announced, so it waits unanswered for the next refresh. + try await executor.checkedUpdateLocalState(identity: identity) { $0.unannouncedOfferIds.formUnion(ids) } + } catch { + Logger.warn("Failed to mark an allowance offer before accepting it: \(error)", context: "PaykitAllowance") + continue + } do { // A background accept must not clear the progress flag of a proposal the user has in flight. try await accept(offer, showsProgress: false) @@ -290,11 +298,12 @@ final class PaykitAllowanceManager { } private func takeUnannouncedOffers(identity: String) async -> [PaykitAllowanceEntry] { - let marked = await executor.localState(identity: identity).unannouncedOfferIds + let marked = await executor.localState(identity: identity).unannouncedOfferIds.subtracting(announcedOfferIds) guard !marked.isEmpty else { return [] } let accepted = entries.filter { entry in entry.allowances.contains { marked.contains($0.scopedId) && $0.role == .allowee && $0.lifecycleState == .accepted } } + announcedOfferIds.formUnion(accepted.flatMap(\.allowances).map(\.scopedId).filter(marked.contains)) // An offer still unanswered keeps its mark; one ended or withdrawn before the accept will never be announced. let settled = Set(allowances.filter { $0.lifecycleState != .proposed }.map(\.scopedId)) await executor.updateLocalState(identity: identity) { $0.unannouncedOfferIds.subtract(settled) } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 1293dced2..d45084701 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1404,6 +1404,46 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(acceptedIdsAfter, [Fixtures.walletAllowanceId]) } + @MainActor + func testManagerAnnouncesAnOfferWhoseMarkCouldNotBeSaved() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + harness.store.failSaves() + + let first = await manager.acceptOffersFromAllowers() + harness.store.restoreSaves() + let second = await manager.acceptOffersFromAllowers() + + XCTAssertEqual((first + second).map(\.counterparty), [Fixtures.counterpartyKey], "An offer accepted for the user is announced") + } + + @MainActor + func testManagerDoesNotAnnounceAnOfferAgainWhenClearingItsMarkFails() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + await harness.sdk.setAcceptAllowanceErrorAfterCommit(CancellationError()) + _ = await manager.acceptOffersFromAllowers() + await harness.sdk.setAcceptAllowanceErrorAfterCommit(nil) + let relaunched = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await relaunched.activate(identity: Fixtures.identityKey) + + harness.store.failSaves() + let announced = await relaunched.acceptOffersFromAllowers() + harness.store.restoreSaves() + let announcedAgain = await relaunched.acceptOffersFromAllowers() + + XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey]) + XCTAssertTrue(announcedAgain.isEmpty, "An offer already announced is not announced again") + } + @MainActor func testAutomaticOfferAcceptanceKeepsAManualProposalWorking() async throws { let harness = AllowanceHarness() From 729685414fdf71a0ee315d447bb1f005a554793e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 10:40:54 +0100 Subject: [PATCH 21/28] fix: retry clearing an announced allowance offer's mark --- Bitkit/Services/PaykitAllowanceManager.swift | 6 ++++-- BitkitTests/PaykitAllowanceExecutorTests.swift | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 1c647f7ab..56228b813 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -298,8 +298,10 @@ final class PaykitAllowanceManager { } private func takeUnannouncedOffers(identity: String) async -> [PaykitAllowanceEntry] { - let marked = await executor.localState(identity: identity).unannouncedOfferIds.subtracting(announcedOfferIds) - guard !marked.isEmpty else { return [] } + let stored = await executor.localState(identity: identity).unannouncedOfferIds + guard !stored.isEmpty else { return [] } + // Marks already returned stay stored until a clear saves; each call retries that clear. + let marked = stored.subtracting(announcedOfferIds) let accepted = entries.filter { entry in entry.allowances.contains { marked.contains($0.scopedId) && $0.role == .allowee && $0.lifecycleState == .accepted } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index d45084701..71d362f1d 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1442,6 +1442,8 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey]) XCTAssertTrue(announcedAgain.isEmpty, "An offer already announced is not announced again") + let marks = await harness.executor.localState(identity: Fixtures.identityKey).unannouncedOfferIds + XCTAssertTrue(marks.isEmpty, "The next refresh retries the clear that failed") } @MainActor From fb623966a36d9c246c8b652381f56c51bacb2930 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 11:08:10 +0100 Subject: [PATCH 22/28] fix: leave a restored allowance attempt open until the node can settle it --- Bitkit/AppScene.swift | 4 ++- Bitkit/Services/PaykitAllowanceExecutor.swift | 21 +++++++++++-- Bitkit/Services/PaykitAllowanceManager.swift | 6 ++++ .../Services/PaykitPaymentProofService.swift | 7 +++-- .../PaykitAllowanceExecutorTests.swift | 30 +++++++++++++++++++ 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 38825f752..47a956ba2 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1252,7 +1252,9 @@ struct AppScene: View { if !PaykitFeatureFlags.isUIEnabled { await retryPendingPaykitEndpointRemoval() } - guard PaykitFeatureFlags.isUIEnabled, isPaykitSceneActive else { return } + guard PaykitFeatureFlags.isUIEnabled else { return } + await paykitAllowanceManager.recoverOpenAttempts() + guard isPaykitSceneActive else { return } await refreshPrivateOnlyPaykitApp() await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() await prepareSavedPaykitContacts() diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 1b5d08c65..9a231368a 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -422,7 +422,8 @@ actor PaykitAllowanceExecutor { let history = current?.history ?? Paykit.AllowanceAccountingHistory(associations: [], occurrences: [], watermarks: []) var outcomes: [Paykit.PaymentOutcomeReport] = [] for attempt in history.occurrences.flatMap(\.attempts) { - if let outcome = await verifiedOutcome(for: attempt, identity: identity) { + // Throws until the node has its payment history, so a restore reconciles only once the node can answer. + if let outcome = try await verifiedOutcome(for: attempt, identity: identity) { outcomes.append(Paykit.PaymentOutcomeReport(attemptId: attempt.attemptId, outcome: outcome)) } } @@ -456,7 +457,14 @@ actor PaykitAllowanceExecutor { guard attempt.epoch == state.epoch else { continue } try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) case .submitted, .unknown: - guard let outcome = await verifiedOutcome(for: attempt, identity: identity) else { + let verified: Paykit.PaymentOutcome? + do { + verified = try await verifiedOutcome(for: attempt, identity: identity) + } catch { + // Left as it is until the node can answer; recovery runs again once it is running. + continue + } + guard let outcome = verified else { if attempt.status == .submitted { try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) } @@ -505,7 +513,10 @@ actor PaykitAllowanceExecutor { return true } - private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async -> Paykit.PaymentOutcome? { + private struct NodeHistoryUnavailable: Error {} + + /// Throws `NodeHistoryUnavailable` when the answer depends on a node that has no payment history yet. + private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async throws -> Paykit.PaymentOutcome? { switch attempt.status { case .prepared: return .failed @@ -530,6 +541,8 @@ actor PaykitAllowanceExecutor { return nil case .failed, .unknown: return .failed + case .unavailable: + throw NodeHistoryUnavailable() } case .succeeded: return .succeeded @@ -547,6 +560,8 @@ actor PaykitAllowanceExecutor { return .failed case .pending, .unknown: return nil + case .unavailable: + throw NodeHistoryUnavailable() } } return entry.transactionId == nil ? nil : .succeeded diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 56228b813..f0d84f107 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -137,6 +137,12 @@ final class PaykitAllowanceManager { await refresh() } + /// Settles open attempts again, for the ones the node could not answer for before it was running. + func recoverOpenAttempts() async { + guard let identity else { return } + await executor.recover(identity: identity) + } + func deactivate() { identity = nil Task { await executor.activate(identity: nil) } diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index e6dad6685..92b3c4274 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -181,6 +181,8 @@ enum PaykitLightningPaymentProofStatus: Equatable { case succeeded(preimage: String?) case failed case unknown + /// The node has no payment history yet, as before it starts, so it can't say whether it knows the payment. + case unavailable } protocol PaykitLightningPaymentProofLookingUp: Sendable { @@ -189,7 +191,8 @@ protocol PaykitLightningPaymentProofLookingUp: Sendable { struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp { func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { - guard let payment = await LightningService.shared.listPayments()?.first(where: { + guard let payments = await LightningService.shared.listPayments() else { return .unavailable } + guard let payment = payments.first(where: { $0.id.caseInsensitiveCompare(paymentHash) == .orderedSame }), payment.direction == .outbound else { return .unknown @@ -1373,7 +1376,7 @@ actor PaykitPaymentProofService { } guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue } switch await lightningPaymentLookup.status(paymentHash: paymentHash) { - case .pending, .unknown: + case .pending, .unknown, .unavailable: continue case .failed: await failLightningPayment(paymentHash: paymentHash) diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 71d362f1d..c23f71032 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -668,6 +668,36 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(journal.map(\.stage), [.succeeded]) } + func testRecoveryLeavesARestoredAttemptOpenUntilTheNodeCanAnswer() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-restored", attempts: [Fixtures.attemptRecord(id: "restored", status: .submitted)]), + ])) + let captured = PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "restored", request: request, stage: .submitted, paymentHash: paidHash), + ]) + try harness.store.seed(PaykitAllowanceStateBackup(captured).restored(), identity: Fixtures.identityKey) + // A restore activates allowances before the node starts, so its payment history is not there yet. + await harness.lookup.setStatuses([paidHash: .unavailable]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomesBeforeStart = await harness.sdk.recordedOutcomes + XCTAssertTrue(outcomesBeforeStart.isEmpty) + let journalBeforeStart = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journalBeforeStart.map(\.stage), [.submitted]) + + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes.map(\.outcome), [.succeeded]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + } + func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() From 08225c9c3e67ea8edb0f97c652f1269a9812bb70 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 16:51:42 +0100 Subject: [PATCH 23/28] fix: forget announced allowance offers on sign-out or profile switch --- Bitkit/Services/PaykitAllowanceManager.swift | 2 ++ .../PaykitAllowanceExecutorTests.swift | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index f0d84f107..3b6827861 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -132,6 +132,7 @@ final class PaykitAllowanceManager { await executor.activate(identity: identity) if identityChanged { manualRequestIds = [:] + announcedOfferIds = [] await executor.recover(identity: identity) } await refresh() @@ -151,6 +152,7 @@ final class PaykitAllowanceManager { autoPaidRequestIds = [] autoPaidSatsByScopedId = [:] manualRequestIds = [:] + announcedOfferIds = [] } func refresh() async { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index c23f71032..5204536b4 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1476,6 +1476,35 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(marks.isEmpty, "The next refresh retries the clear that failed") } + @MainActor + func testManagerForgetsWhatItAnnouncedWhenDeactivated() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + await harness.sdk.setAcceptAllowanceErrorAfterCommit(CancellationError()) + _ = await manager.acceptOffersFromAllowers() + await harness.sdk.setAcceptAllowanceErrorAfterCommit(nil) + let relaunched = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await relaunched.activate(identity: Fixtures.identityKey) + harness.store.failSaves() + let announced = await relaunched.acceptOffersFromAllowers() + + relaunched.deactivate() + await relaunched.activate(identity: Fixtures.identityKey) + let announcedAfterReactivation = await relaunched.acceptOffersFromAllowers() + harness.store.restoreSaves() + + XCTAssertEqual(announced.map(\.counterparty), [Fixtures.counterpartyKey]) + XCTAssertEqual( + announcedAfterReactivation.map(\.counterparty), + [Fixtures.counterpartyKey], + "A new session announces an offer whose mark is still stored" + ) + } + @MainActor func testAutomaticOfferAcceptanceKeepsAManualProposalWorking() async throws { let harness = AllowanceHarness() From e71b59169d571c6652ba0c2e0659eecf279536b5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 18:05:05 +0100 Subject: [PATCH 24/28] fix: give back a consumed payment list after a restart --- Bitkit/Services/PaykitAllowanceExecutor.swift | 136 ++++++++++-------- .../PrivatePaykitService+Models.swift | 3 + .../PrivatePaykitService+Payments.swift | 26 +++- .../Services/PrivatePaykitService+State.swift | 1 + .../PaykitAllowanceExecutorTests.swift | 100 +++++++++++-- .../PaykitPaymentRequestServiceTests.swift | 68 +++++++++ 6 files changed, 262 insertions(+), 72 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 9a231368a..815444de8 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -174,13 +174,21 @@ struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { /// The side effects of paying one request, behind a protocol so the admission logic is testable without a node. protocol PaykitAllowancePaying: Sendable { func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? - func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws + /// Consumes the list for the journaled attempt `owner`, only while the list consumed so far is `expectedPreviousVersion`. + func consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + owner: String, + expectedPreviousVersion: UInt64? + ) async throws func resolvePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID, outcome: PrivatePaymentListSendOutcome) async func claimForExecution(_ request: PaykitPaymentRequest) async throws - /// The list version a consumption replaced, read while its in-memory record still exists. - func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? - /// Rolls back a list a failed automatic payment consumed, when it is still the contact's consumed version. - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async + /// The contact's list version consumed so far. + func consumedPaymentListVersion(publicKey: String) async -> UInt64? + /// Rolls back a list a failed automatic payment consumed, when `owner` consumed the contact's current version. + /// Returns false only when the rollback could not be saved, so recovery tries it again. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) async -> Bool /// Keeps this device the local owner of the request across the automatic Acceptance, as the manual flow does, so a /// request that was accepted but never paid comes back to the payer as an ordinary Payment Request. /// Returns whether this call added the intent, so a refused acceptance never drops one the manual flow saved. @@ -201,8 +209,20 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PrivatePaykitService.shared.resolveAllowancePayment(request, eligibleIdentifiers: eligibleIdentifiers) } - func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { - try await PrivatePaykitService.shared.consumePrivatePaymentList(publicKey: publicKey, context: context, attemptId: attemptId) + func consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + owner: String, + expectedPreviousVersion: UInt64? + ) async throws { + try await PrivatePaykitService.shared.consumePrivatePaymentList( + publicKey: publicKey, + context: context, + attemptId: attemptId, + owner: owner, + expectedPreviousVersion: .some(expectedPreviousVersion) + ) } func resolvePaymentList( @@ -227,15 +247,22 @@ struct PaykitAllowanceLivePayer: PaykitAllowancePaying { try await PaykitPaymentRequestService().claimForPayment(request) } - func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { - await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey, attemptId: attemptId) + func consumedPaymentListVersion(publicKey: String) async -> UInt64? { + await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey) } - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) async -> Bool { do { - try await PrivatePaykitService.shared.releaseConsumedPaymentList(publicKey: publicKey, version: version, previousVersion: previousVersion) + try await PrivatePaykitService.shared.releaseConsumedPaymentList( + publicKey: publicKey, + version: version, + previousVersion: previousVersion, + owner: owner + ) + return true } catch { Logger.warn("Failed to release a payment list a failed automatic payment consumed: \(error)", context: "PaykitAllowance") + return false } } @@ -445,6 +472,8 @@ actor PaykitAllowanceExecutor { do { // No ledger means nothing was ever admitted, so there is nothing to recover and no reason to create one at launch. guard try await sdk.allowanceAccountingState() != nil else { return } + // Needs no node, so it runs before reconciliation, which waits for the node's payment history. + await retryPaymentListReleases(identity: identity) let state = try await ensureReconciled(identity: identity) // One journal read for the whole pass: each attempt appears once, so a stage changed below is never read again. let journalStages = Dictionary( @@ -480,7 +509,7 @@ actor PaykitAllowanceExecutor { let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), outcome == .failed { - await releaseConsumedPaymentList(of: entry) + await releaseConsumedPaymentList(of: entry, identity: identity) } } } @@ -572,18 +601,32 @@ actor PaykitAllowanceExecutor { // A settlement and recovery can both record one failure; only the call that ends the attempt releases its list. let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity, onlyIfOpen: outcome == .failed) if outcome == .failed { - await releaseConsumedPaymentList(of: entry) + await releaseConsumedPaymentList(of: entry, identity: identity) } } - /// A failed automatic payment leaves the payee's list usable for the manual payment that follows. - private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?) async { + /// A failed automatic payment leaves the payee's list usable for the manual payment that follows. The journal keeps + /// the version until the release is saved, so recovery retries one that could not be. + private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?, identity: String) async { guard let entry, entry.isAutomatic, let version = entry.paymentListVersion else { return } - await payer.releaseConsumedPaymentList( + let released = await payer.releaseConsumedPaymentList( publicKey: entry.requestId.counterparty, version: version, - previousVersion: entry.previousPaymentListVersion + previousVersion: entry.previousPaymentListVersion, + owner: entry.attemptId ) + guard released else { return } + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == entry.attemptId }) else { return } + state.journal[index].paymentListVersion = nil + state.journal[index].previousPaymentListVersion = nil + } + } + + private func retryPaymentListReleases(identity: String) async { + for entry in localState(identity: identity).journal where entry.stage == .failed && entry.paymentListVersion != nil { + await releaseConsumedPaymentList(of: entry, identity: identity) + } } /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, @@ -834,15 +877,23 @@ actor PaykitAllowanceExecutor { let listAttemptId = UUID() do { - try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: listAttemptId) - if let consumedVersion = payment.context.paymentListVersion { - let previousVersion = await payer.previousPaymentListVersion(publicKey: request.counterparty, attemptId: listAttemptId) - updateLocalState(identity: identity) { state in + let previousVersion = await payer.consumedPaymentListVersion(publicKey: request.counterparty) + if let listVersion = payment.context.paymentListVersion { + // Journaled before the list is consumed, so recovery gives it back after a restart at any point; the list + // records this attempt as its owner, so recovery never gives back one this attempt did not consume. + try checkedUpdateLocalState(identity: identity) { state in guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { return } - state.journal[index].paymentListVersion = consumedVersion + state.journal[index].paymentListVersion = listVersion state.journal[index].previousPaymentListVersion = previousVersion } } + try await payer.consumePaymentList( + publicKey: request.counterparty, + context: payment.context, + attemptId: listAttemptId, + owner: submitted.attemptId, + expectedPreviousVersion: previousVersion + ) try await payer.ensurePaymentAllowed(request) try await payer.prepareProof( request, @@ -852,10 +903,7 @@ actor PaykitAllowanceExecutor { ) } catch { await payer.cancelProofPreparation(request) - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: submitted.attemptId, identity: identity, - outcome: .definitePreBroadcastFailure - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: submitted.attemptId, outcome: .failed, identity: identity) throw error } @@ -874,26 +922,8 @@ actor PaykitAllowanceExecutor { _ request: PaykitPaymentRequest, payment: PrivatePaykitAllowancePayment, attemptId: UUID, - journalAttemptId: String, - identity: String, outcome: PrivatePaymentListSendOutcome ) async { - var outcome = outcome - if outcome == .definitePreBroadcastFailure { - // Released here, so recording the attempt failed must not release it again: by then another payment may - // have consumed the same list version. A claim that cannot be cleared stays for that recording to release. - do { - var state = try store.load(identity: identity) - if let index = state.journal.firstIndex(where: { $0.attemptId == journalAttemptId }) { - state.journal[index].paymentListVersion = nil - state.journal[index].previousPaymentListVersion = nil - try store.save(state, identity: identity) - } - } catch { - Logger.warn("Failed to clear a payment list claim; the failure record releases it: \(error)", context: "PaykitAllowance") - outcome = .uncertain - } - } await payer.resolvePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: attemptId, outcome: outcome) } @@ -910,10 +940,7 @@ actor PaykitAllowanceExecutor { try await payer.associateLightningPayment(request, paymentHash: paymentHash) } catch { await payer.cancelProofPreparation(request) - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, - outcome: .definitePreBroadcastFailure - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } @@ -928,24 +955,17 @@ actor PaykitAllowanceExecutor { guard await payer.failLightningPayment(paymentHash: paymentHash, submissionError: error) else { // The node may have started the payment; its settlement event records the outcome. Logger.warn("Automatic Lightning allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) if !hasTerminalStage(attemptId: attemptId, identity: identity) { try await record(attemptId: attemptId, outcome: .unknown, identity: identity) } return .started } - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, - outcome: .definitePreBroadcastFailure - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .definitePreBroadcastFailure) try await record(attemptId: attemptId, outcome: .failed, identity: identity) throw error } - await releasePaymentList( - request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain - ) + await releasePaymentList(request, payment: payment, attemptId: listAttemptId, outcome: .uncertain) setStage(.sent, attemptId: attemptId, identity: identity) Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance") return .started diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index 590477986..aa9324669 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -10,6 +10,9 @@ extension PrivatePaykitService { struct ContactState: Codable { var cachedResolvedEndpoints: [StoredPaymentEntry] = [] var consumedPrivatePaymentListVersion: UInt64? + /// The automatic payment attempt that consumed the list, so only that attempt gives it back. Local to this device: + /// the wallet backup carries the version alone. + var consumedPrivatePaymentListOwner: String? var localInvoice: StoredInvoice? var receivedInvoicePaymentHashes: [String] = [] var hasPublishedPrivatePaymentList = false diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 6de3653f8..a60f1ff29 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -257,10 +257,13 @@ extension PrivatePaykitService { ) } + /// `expectedPreviousVersion`, when given, makes the consumption fail unless the list consumed so far is still that version. func consumePrivatePaymentList( publicKey: String, context: PrivatePaykitPaymentContext, - attemptId: UUID + attemptId: UUID, + owner: String? = nil, + expectedPreviousVersion: UInt64?? = nil ) throws { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey @@ -274,9 +277,13 @@ extension PrivatePaykitService { { throw PrivatePaykitError.paymentListAlreadyConsumed } + if let expectedPreviousVersion, expectedPreviousVersion != consumedVersion { + throw PrivatePaykitError.paymentListAlreadyConsumed + } let previousContactState = state.contacts[publicKey] contactState.consumedPrivatePaymentListVersion = paymentListVersion + contactState.consumedPrivatePaymentListOwner = owner contactState.cachedResolvedEndpoints.removeAll() state.contacts[publicKey] = contactState do { @@ -332,6 +339,7 @@ extension PrivatePaykitService { let previousContactState = state.contacts[publicKey] contactState.consumedPrivatePaymentListVersion = consumption.previousPaymentListVersion + contactState.consumedPrivatePaymentListOwner = nil state.contacts[publicKey] = contactState do { try persistStateOrThrow(markWalletBackup: true) @@ -347,14 +355,18 @@ extension PrivatePaykitService { } /// Rolls back a list version a failed automatic payment consumed, which a restart may have left without its - /// in-memory consumption record. A newer consumption is left alone. - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) throws { + /// in-memory consumption record. A list another payment consumed, or one this attempt never consumed, is left alone. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) throws { guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } - guard var contactState = state.contacts[publicKey], contactState.consumedPrivatePaymentListVersion == version else { return } + guard var contactState = state.contacts[publicKey], + contactState.consumedPrivatePaymentListVersion == version, + contactState.consumedPrivatePaymentListOwner == owner + else { return } let previousContactState = state.contacts[publicKey] contactState.consumedPrivatePaymentListVersion = previousVersion + contactState.consumedPrivatePaymentListOwner = nil state.contacts[publicKey] = contactState do { try persistStateOrThrow(markWalletBackup: true) @@ -385,6 +397,7 @@ extension PrivatePaykitService { } let previous = state.contacts[key] contact.consumedPrivatePaymentListVersion = version + contact.consumedPrivatePaymentListOwner = nil contact.cachedResolvedEndpoints.removeAll() state.contacts[key] = contact do { @@ -398,7 +411,10 @@ extension PrivatePaykitService { /// The caller must establish an exact receipt that definitely never reached native dispatch. func releaseUnsentPaymentListVersion(publicKey: String, version: UInt64, previousVersion: UInt64? = nil) throws { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { throw PrivatePaykitError.invalidPublicKey } - guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version else { return } + // A list an automatic payment attempt consumed is that attempt's to give back. + guard var contact = state.contacts[key], contact.consumedPrivatePaymentListVersion == version, + contact.consumedPrivatePaymentListOwner == nil + else { return } let previous = contact guard previousVersion.map({ $0 < version }) ?? true else { throw PaykitPaymentRequestError.requestUnavailable } contact.consumedPrivatePaymentListVersion = previousVersion diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index 404488c90..5341fee19 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -31,6 +31,7 @@ extension PrivatePaykitService { } else { var contactState = ContactState() contactState.consumedPrivatePaymentListVersion = consumedVersion + contactState.consumedPrivatePaymentListOwner = state.contacts[key]?.consumedPrivatePaymentListOwner state.contacts[key] = contactState } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 5204536b4..c5e150728 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -616,7 +616,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(exceeds, "Only an allowance active now can take a request") } - func testPaymentListReleasedBeforeBroadcastIsNeverReleasedAgain() async throws { + func testPaymentListReleasedBeforeBroadcastIsReleasedAgainOnlyForItsOwner() async throws { let harness = AllowanceHarness() await harness.payer.setLightningError(AllowanceMockError.unsupported) @@ -624,8 +624,12 @@ final class PaykitAllowanceExecutorTests: XCTestCase { let listOutcomes = await harness.payer.paymentListOutcomes XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) - let released = await harness.payer.releasedLists - XCTAssertEqual(released, [], "A second release could clear another payment's consumption of the same version") + let owners = await harness.payer.releaseOwners + XCTAssertEqual( + owners, + [AllowanceSdkMock.automaticAttemptId], + "The failure record releases only a list this attempt still owns, so another payment's consumption is left alone" + ) let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal XCTAssertEqual(journal.map(\.paymentListVersion), [nil]) } @@ -698,6 +702,49 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(journal.map(\.stage), [.succeeded]) } + func testPaymentListIsNotConsumedWhenItsVersionCannotBeJournaled() async { + let harness = AllowanceHarness() + await harness.payer.failSavesOnListRead(harness.store) + + _ = try? await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertFalse(harness.log.entries.contains("consumePaymentList"), "A restart must find every consumed list in the journal") + } + + func testAutomaticPaymentConsumesThePaymentListAsItsAttempt() async throws { + let harness = AllowanceHarness() + + _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let owners = await harness.payer.consumeOwners + XCTAssertEqual(owners, [AllowanceSdkMock.automaticAttemptId]) + } + + func testRecoveryRetriesAPaymentListReleaseThatCouldNotBeSaved() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "failed", request: request, stage: .failed, paymentListVersion: 5)]), + identity: Fixtures.identityKey + ) + await harness.payer.setReleaseSucceeds(false) + + await harness.executor.recover(identity: Fixtures.identityKey) + let kept = await harness.executor.localState(identity: Fixtures.identityKey).journal.map(\.paymentListVersion) + await harness.payer.setReleaseSucceeds(true) + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + XCTAssertEqual(kept, [5], "A release that could not be saved keeps the version for the next recovery") + let owners = await harness.payer.releaseOwners + XCTAssertEqual(owners, ["failed", "failed"], "Recovery retries once, as the attempt that consumed the list, then stops") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.paymentListVersion), [nil]) + } + func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws { let harness = AllowanceHarness() let request = try Fixtures.paymentRequest() @@ -713,6 +760,8 @@ final class PaykitAllowanceExecutorTests: XCTestCase { let released = await harness.payer.releasedLists XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 3, previousVersion: 2)]) + let owners = await harness.payer.releaseOwners + XCTAssertEqual(owners, ["killed"], "Only the attempt that consumed the list gives it back") } func testRecoveryReleasesThePaymentListOfAnAttemptReconciliationFailed() async throws { @@ -815,7 +864,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(released, [5], "The list is released once, by the settlement") } - func testPaymentListIsNotReleasedBeforeBroadcastWhenItsClaimCannotBeCleared() async throws { + func testRecoveryReleasesThePaymentListOfAFailureThatCouldNotBeSaved() async throws { let harness = AllowanceHarness() await harness.payer.setLightningError(AllowanceMockError.unsupported) await harness.payer.failSavesOnNodeRejection(harness.store) @@ -823,7 +872,7 @@ final class PaykitAllowanceExecutorTests: XCTestCase { _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) let listOutcomes = await harness.payer.paymentListOutcomes - XCTAssertEqual(listOutcomes, [.uncertain], "A claim still saved in the journal must not be released twice") + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) let releasedWhileSavesFail = await harness.payer.releasedLists XCTAssertEqual(releasedWhileSavesFail, [], "A failure that can't be saved leaves the release to the recovery that saves it") @@ -1935,8 +1984,25 @@ private actor AllowancePayerMock: PaykitAllowancePaying { return payment } - func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { + private(set) var consumeOwners: [String] = [] + private var consumeError: Error? + + func setConsumeError(_ error: Error?) { + consumeError = error + } + + func consumePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + owner: String, + expectedPreviousVersion: UInt64? + ) async throws { called("consumePaymentList") + if let consumeError { + throw consumeError + } + consumeOwners.append(owner) } func resolvePaymentList( @@ -1956,9 +2022,23 @@ private actor AllowancePayerMock: PaykitAllowancePaying { } private(set) var releasedLists: [ReleasedList] = [] + private(set) var releaseOwners: [String] = [] + private var releaseSucceeds = true + + func setReleaseSucceeds(_ succeeds: Bool) { + releaseSucceeds = succeeds + } + + private var storeFailingOnListRead: AllowanceMemoryStore? + + /// Fails journal saves from the read just before the list's version is journaled. + func failSavesOnListRead(_ store: AllowanceMemoryStore) { + storeFailingOnListRead = store + } - func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { - nil + func consumedPaymentListVersion(publicKey: String) async -> UInt64? { + storeFailingOnListRead?.failSaves() + return nil } private var releaseGate: AllowanceGate? @@ -1970,13 +2050,15 @@ private actor AllowancePayerMock: PaykitAllowancePaying { return gate } - func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?, owner: String) async -> Bool { called("releaseConsumedPaymentList") releasedLists.append(ReleasedList(publicKey: publicKey, version: version, previousVersion: previousVersion)) + releaseOwners.append(owner) if let gate = releaseGate { releaseGate = nil await gate.wait() } + return releaseSucceeds } func claimForExecution(_ request: PaykitPaymentRequest) async throws { diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2fb81a4bc..7067b5ed2 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1146,6 +1146,74 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testReleaseGivesBackOnlyAListItsOwnAttemptConsumed() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 6), attemptId: UUID()) + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 7), attemptId: UUID(), owner: "attempt-a") + + try await service.releaseConsumedPaymentList(publicKey: publicKey, version: 7, previousVersion: 6, owner: "attempt-b") + let afterOtherOwner = await service.state.contacts[publicKey] + try await service.releaseConsumedPaymentList(publicKey: publicKey, version: 7, previousVersion: 6, owner: "attempt-a") + let afterOwner = await service.state.contacts[publicKey] + + XCTAssertEqual(afterOtherOwner?.consumedPrivatePaymentListVersion, 7) + XCTAssertEqual(afterOwner?.consumedPrivatePaymentListVersion, 6) + XCTAssertNil(afterOwner?.consumedPrivatePaymentListOwner) + } + + func testReleaseLeavesAManualPaymentsConsumptionOfTheSameVersion() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 7), attemptId: UUID()) + + // An automatic attempt journaled version 7 but a restart came before it consumed the list. + try await service.releaseConsumedPaymentList(publicKey: publicKey, version: 7, previousVersion: nil, owner: "attempt-a") + + let contact = await service.state.contacts[publicKey] + XCTAssertEqual(contact?.consumedPrivatePaymentListVersion, 7) + } + + func testConsumeFailsWhenTheListChangedSinceItsPreviousVersionWasRead() async throws { + snapshotAppDefaultsDomain() + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + let service = PrivatePaykitService() + let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + try await service.consumePrivatePaymentList(publicKey: publicKey, context: listContext(version: 6), attemptId: UUID()) + + do { + try await service.consumePrivatePaymentList( + publicKey: publicKey, + context: listContext(version: 7), + attemptId: UUID(), + owner: "attempt-a", + expectedPreviousVersion: .some(nil) + ) + XCTFail("A list consumed since the attempt read it must not be consumed over") + } catch PrivatePaykitError.paymentListAlreadyConsumed {} + + let contact = await service.state.contacts[publicKey] + XCTAssertEqual(contact?.consumedPrivatePaymentListVersion, 6) + XCTAssertNil(contact?.consumedPrivatePaymentListOwner) + } + + func testContactStateSavedBeforeListOwnersDecodes() throws { + let saved = #"{"cachedResolvedEndpoints":[],"consumedPrivatePaymentListVersion":3,"receivedInvoicePaymentHashes":[],"hasPublishedPrivatePaymentList":false}"# + + let contact = try JSONDecoder().decode(PrivatePaykitService.ContactState.self, from: Data(saved.utf8)) + + XCTAssertEqual(contact.consumedPrivatePaymentListVersion, 3) + XCTAssertNil(contact.consumedPrivatePaymentListOwner) + } + + private func listContext(version: UInt64) -> PrivatePaykitPaymentContext { + PrivatePaykitPaymentContext(paymentAppsByEndpoint: [:], paymentListVersion: version) + } + func testUsdtOnlyPrivateContactRetainsItsEndpointAndListVersion() async throws { snapshotAppDefaultsDomain() UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) From 3335054b14ace93007176a4febaff097312aad04 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 19:26:52 +0100 Subject: [PATCH 25/28] fix: never consume a payment list for an attempt the journal lost --- Bitkit/Services/PaykitAllowanceExecutor.swift | 11 +++++-- .../PaykitAllowanceExecutorTests.swift | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 815444de8..13a12986f 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -412,9 +412,9 @@ actor PaykitAllowanceExecutor { } } - func checkedUpdateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) -> Void) throws { + func checkedUpdateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) throws -> Void) throws { var state = try store.load(identity: identity) - change(&state) + try change(&state) try store.save(state, identity: identity) } @@ -882,7 +882,10 @@ actor PaykitAllowanceExecutor { // Journaled before the list is consumed, so recovery gives it back after a restart at any point; the list // records this attempt as its owner, so recovery never gives back one this attempt did not consume. try checkedUpdateLocalState(identity: identity) { state in - guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { return } + // An attempt whose journal entry was never saved could not be recovered, so its list is not consumed. + guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { + throw JournalEntryMissing() + } state.journal[index].paymentListVersion = listVersion state.journal[index].previousPaymentListVersion = previousVersion } @@ -1094,6 +1097,8 @@ actor PaykitAllowanceExecutor { ) } + private struct JournalEntryMissing: Error {} + private func journal(_ entry: PaykitAllowanceLocalState.JournalEntry, identity: String) { updateLocalState(identity: identity) { state in state.journal.removeAll { $0.attemptId == entry.attemptId } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index c5e150728..343de290a 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -711,6 +711,20 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertFalse(harness.log.entries.contains("consumePaymentList"), "A restart must find every consumed list in the journal") } + func testPaymentListIsNotConsumedWhenTheAttemptWasNeverJournaled() async { + let harness = AllowanceHarness() + await harness.sdk.failJournalAfterReservation(harness.store) + + _ = try? await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertFalse(journal.contains { $0.attemptId == AllowanceSdkMock.automaticAttemptId && $0.paymentListVersion != nil }) + XCTAssertFalse( + harness.log.entries.contains("consumePaymentList"), + "Later saves succeed, but recovery would not find this attempt, so its list must not be consumed" + ) + } + func testAutomaticPaymentConsumesThePaymentListAsItsAttempt() async throws { let harness = AllowanceHarness() @@ -1806,6 +1820,7 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen private let lock = NSLock() private var states: [String: PaykitAllowanceLocalState] = [:] private var savesFail = false + private var failingSaves = 0 func load(identity: String) throws -> PaykitAllowanceLocalState { lock.withLock { states[identity] ?? PaykitAllowanceLocalState() } @@ -1816,6 +1831,10 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen if savesFail { throw AllowanceMockError.unsupported } + if failingSaves > 0 { + failingSaves -= 1 + throw AllowanceMockError.unsupported + } states[identity] = state } } @@ -1828,6 +1847,10 @@ private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sen lock.withLock { savesFail = false } } + func failNextSave() { + lock.withLock { failingSaves = 1 } + } + func seed(_ state: PaykitAllowanceLocalState, identity: String) { lock.withLock { states[identity] = state } } @@ -2332,12 +2355,21 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { ) } + private var storeFailingJournal: AllowanceMemoryStore? + + /// Fails the next journal save, the one that records the attempt this reservation returns. + func failJournalAfterReservation(_ store: AllowanceMemoryStore) { + storeFailingJournal = store + } + func reserveAutomaticPayment( occurrence: Paykit.PaymentOccurrence, expectedAssociationRevision: UInt64, checks: Paykit.PaymentExecutionChecks ) async throws -> Paykit.PaymentAttemptDecision { log.append("reserveAutomaticPayment") + storeFailingJournal?.failNextSave() + storeFailingJournal = nil reservedAssociationRevisions.append(expectedAssociationRevision) if let automaticReservation { return automaticReservation From 73a4fcfe77cc8096acd874a153b2877fc293cb89 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 19:26:53 +0100 Subject: [PATCH 26/28] fix: keep a payment list's owner through the wallet backup --- Bitkit/Services/PrivatePaykitService+Backup.swift | 4 ++++ Bitkit/Services/PrivatePaykitService+Models.swift | 5 +++-- BitkitTests/PrivatePaykitServiceTests.swift | 13 ++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 589b4c5cf..b4c0e4a27 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -14,6 +14,9 @@ extension PrivatePaykitService { sdkState: exported.state, consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in contactState.consumedPrivatePaymentListVersion + }, + consumedPrivatePaymentListOwners: state.contacts.compactMapValues { contactState in + contactState.consumedPrivatePaymentListVersion == nil ? nil : contactState.consumedPrivatePaymentListOwner } ) let data = try JSONEncoder().encode(backup) @@ -37,6 +40,7 @@ extension PrivatePaykitService { if let decoded { for (publicKey, versions) in decoded.consumedPrivatePaymentListVersions { state.contacts[publicKey, default: ContactState()].consumedPrivatePaymentListVersion = versions + state.contacts[publicKey]?.consumedPrivatePaymentListOwner = decoded.consumedPrivatePaymentListOwners?[publicKey] } } else { await PaykitSdkService.shared.clearState() diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift index aa9324669..0ceacfa1a 100644 --- a/Bitkit/Services/PrivatePaykitService+Models.swift +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -10,8 +10,7 @@ extension PrivatePaykitService { struct ContactState: Codable { var cachedResolvedEndpoints: [StoredPaymentEntry] = [] var consumedPrivatePaymentListVersion: UInt64? - /// The automatic payment attempt that consumed the list, so only that attempt gives it back. Local to this device: - /// the wallet backup carries the version alone. + /// The automatic payment attempt that consumed the list, so only that attempt gives it back, also after a restore. var consumedPrivatePaymentListOwner: String? var localInvoice: StoredInvoice? var receivedInvoicePaymentHashes: [String] = [] @@ -57,5 +56,7 @@ extension PrivatePaykitService { struct Backup: Codable { let sdkState: String let consumedPrivatePaymentListVersions: [String: UInt64] + /// Absent from backups made before list owners existed. + var consumedPrivatePaymentListOwners: [String: String]? = nil } } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 3740ad349..8682466a8 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1749,7 +1749,8 @@ final class PrivatePaykitServiceTests: XCTestCase { let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let backup = PrivatePaykitService.Backup( sdkState: "opaque recovery state", - consumedPrivatePaymentListVersions: [publicKey: 7] + consumedPrivatePaymentListVersions: [publicKey: 7], + consumedPrivatePaymentListOwners: [publicKey: "attempt-a"] ) let encoded = try String(decoding: JSONEncoder().encode(backup), as: UTF8.self) let service = PrivatePaykitService() @@ -1759,10 +1760,20 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(try Keychain.loadString(key: .paykitRecoveryBackup), backup.sdkState) let state = await service.testContactState(publicKey: publicKey) XCTAssertEqual(state?.consumedPrivatePaymentListVersion, 7) + XCTAssertEqual(state?.consumedPrivatePaymentListOwner, "attempt-a", "A restored failed attempt can still give its list back") XCTAssertNil(try Keychain.load(key: .paykitSession)) XCTAssertNil(try Keychain.load(key: .pubkySecretKey)) } + func testBackupMadeBeforeListOwnersDecodes() throws { + let saved = #"{"sdkState":"opaque recovery state","consumedPrivatePaymentListVersions":{"pubkycontact":7}}"# + + let backup = try JSONDecoder().decode(PrivatePaykitService.Backup.self, from: Data(saved.utf8)) + + XCTAssertEqual(backup.consumedPrivatePaymentListVersions, ["pubkycontact": 7]) + XCTAssertNil(backup.consumedPrivatePaymentListOwners) + } + func testReservationStoreBacksUpRestoredCeiling() async throws { let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) From f08577398630bc86750f19e1339bc34c8c95762d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 27 Oct 2026 20:05:50 +0100 Subject: [PATCH 27/28] docs: match the ended allowance row in the end journey --- journeys/allowances/end-stops-auto-pay.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml index 582b67264..e918600bf 100644 --- a/journeys/allowances/end-stops-auto-pay.xml +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -19,7 +19,7 @@ Set a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml; the payee's wallet accepts it by itself On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end Verify the payee's row reads "Ended" - On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" + On the payer instance, verify its row for that allowance reads "Ended", with no amount since nothing was paid automatically On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it From f3b582580e9ba665ae37219a2aea0e20974ba460 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 14:52:39 +0100 Subject: [PATCH 28/28] fix: leave a payment in progress out of recovery Recovery also runs when the node starts, so a node restart during an automatic payment could record its attempt as failed while the send went ahead. Recovery now skips requests the executor is paying. --- Bitkit/Services/PaykitAllowanceExecutor.swift | 66 +++++++++++-------- .../PaykitAllowanceExecutorTests.swift | 24 +++++++ 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 13a12986f..0d33aa32d 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -480,36 +480,44 @@ actor PaykitAllowanceExecutor { localState(identity: identity).journal.map { ($0.attemptId, $0.stage) }, uniquingKeysWith: { first, _ in first } ) - for attempt in state.history.occurrences.flatMap(\.attempts) { - switch attempt.status { - case .prepared: - guard attempt.epoch == state.epoch else { continue } - try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) - case .submitted, .unknown: - let verified: Paykit.PaymentOutcome? - do { - verified = try await verifiedOutcome(for: attempt, identity: identity) - } catch { - // Left as it is until the node can answer; recovery runs again once it is running. - continue - } - guard let outcome = verified else { - if attempt.status == .submitted { - try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) + for occurrence in state.history.occurrences { + let requestId = PaykitPaymentRequest.ID( + paymentRequestId: occurrence.key.request.paymentRequestId, + counterparty: occurrence.key.request.counterparty + ) + // A payment in progress owns its attempts; checked per occurrence, since one can start while this loop awaits. + guard !inFlightRequestIds.contains(requestId) else { continue } + for attempt in occurrence.attempts { + switch attempt.status { + case .prepared: + guard attempt.epoch == state.epoch else { continue } + try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) + case .submitted, .unknown: + let verified: Paykit.PaymentOutcome? + do { + verified = try await verifiedOutcome(for: attempt, identity: identity) + } catch { + // Left as it is until the node can answer; recovery runs again once it is running. + continue + } + guard let outcome = verified else { + if attempt.status == .submitted { + try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) + } + continue + } + try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) + case .succeeded, .failed: + // Reconciliation can settle an attempt the journal still shows as open. + let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed + // The cached stage only skips settled attempts cheaply; a settlement while this loop awaited may have + // settled one since, so the move itself checks the current stage. + if let stage = journalStages[attempt.attemptId], !stage.isTerminal, + let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), + outcome == .failed + { + await releaseConsumedPaymentList(of: entry, identity: identity) } - continue - } - try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) - case .succeeded, .failed: - // Reconciliation can settle an attempt the journal still shows as open. - let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed - // The cached stage only skips settled attempts cheaply; a settlement while this loop awaited may have - // settled one since, so the move itself checks the current stage. - if let stage = journalStages[attempt.attemptId], !stage.isTerminal, - let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), - outcome == .failed - { - await releaseConsumedPaymentList(of: entry, identity: identity) } } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 343de290a..b0f9f8ba2 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -444,6 +444,30 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertEqual(journal.map(\.stage), [.succeeded]) } + func testRecoveryLeavesTheAttemptOfAPaymentInProgressAlone() async throws { + let harness = AllowanceHarness() + let gate = await harness.payer.holdLightning() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(occurrences: [ + Fixtures.occurrence( + requestId: request.paymentRequestId, + attempts: [Fixtures.attemptRecord(id: AllowanceSdkMock.automaticAttemptId, status: .prepared)] + ), + ])) + await harness.executor.activate(identity: Fixtures.identityKey) + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payLightning") } + await harness.executor.recover(identity: Fixtures.identityKey) + let outcomesDuringTheSend = await harness.sdk.recordedOutcomes + gate.open() + _ = await caller.value + + XCTAssertEqual(outcomesDuringTheSend, [], "A node restart during the send must not fail the attempt being sent") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.sent]) + } + // MARK: Cancellation func testLightningPaymentStillStartsWhenItsCallerIsCancelledDuringTheSend() async throws {