From f39525891c417cf4ce0bd8276d6f52502289c65f Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 17:37:49 -0600 Subject: [PATCH 1/3] fix: defer passive proof display refreshes --- Bitkit/AppScene.swift | 12 +- .../PaykitPaymentRequestService.swift | 3 +- BitkitTests/PaykitPaymentActivityTests.swift | 9 +- .../PaykitPaymentRequestServiceTests.swift | 149 ++++++++++++------ .../next/proof-display-admission.fixed.md | 1 + 5 files changed, 121 insertions(+), 53 deletions(-) create mode 100644 changelog.d/next/proof-display-admission.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b26bb14e7..a0163e0bf 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -698,11 +698,13 @@ struct AppScene: View { .task(id: pendingPaykitProofRefresh) { guard let request = pendingPaykitProofRefresh else { return } do { - try await PaykitPaymentActivity.shared.waitUntilIdle() + try await PaykitPaymentActivity.shared.waitUntilIdle(includingReviews: true) } catch { return } guard !Task.isCancelled, request == pendingPaykitProofRefresh else { return } - await refreshIncomingPaykitPaymentRequests(mode: .stored, forceFresh: true, automatic: false) - guard !Task.isCancelled else { return } + let refreshed = await refreshIncomingPaykitPaymentRequests( + mode: .stored, forceFresh: true, refreshPriority: .background, automatic: false + ) + guard !Task.isCancelled, refreshed else { return } paykitProofRefresh.complete(request) } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in @@ -1424,6 +1426,7 @@ struct AppScene: View { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .background, + refreshPriority: PaykitSdkOperationLock.Priority? = nil, onlyWhileActive: Bool = false, automatic: Bool = true ) async -> Bool { @@ -1455,7 +1458,8 @@ struct AppScene: View { guard canRefresh() else { return false } let identity = session.publicKey let refreshed = await paykitPaymentRequestManager.refresh( - mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh + mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, + refreshPriority: refreshPriority, canRefresh: canRefresh ) guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 926c8eb88..42eb3ff98 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1803,13 +1803,14 @@ final class PaykitPaymentRequestManager { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .background, + refreshPriority: PaykitSdkOperationLock.Priority? = nil, canRefresh: @escaping @MainActor () -> Bool = { true } ) async -> Bool { await refresh( excludingProtectedRequestId: nil, mode: mode, forceFresh: forceFresh, - messagePriority: forceFresh ? .ordered : messagePriority, + messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority), canRefresh: canRefresh ) } diff --git a/BitkitTests/PaykitPaymentActivityTests.swift b/BitkitTests/PaykitPaymentActivityTests.swift index af3dddaf9..612260967 100644 --- a/BitkitTests/PaykitPaymentActivityTests.swift +++ b/BitkitTests/PaykitPaymentActivityTests.swift @@ -50,13 +50,14 @@ final class PaykitPaymentActivityTests: XCTestCase { XCTAssertTrue(didRefresh) } - func testProofInvalidationsCoalesceUntilForegroundAndPaymentIdle() async throws { + func testProofInvalidationsCoalesceUntilForegroundAndPaymentReviewIdle() async throws { let profile = PubkyProfileManager() profile.publicKey = "pubky\(String(repeating: "z", count: 52))" let session = try XCTUnwrap(profile.currentSession) var state = PaykitPaymentProofRefreshState() let activity = PaykitPaymentActivity() let payment = activity.begin() + let review = activity.beginReview() for _ in 0 ..< 3 { state.invalidate(session: session) XCTAssertNil(state.request(session: session, isActive: false)) @@ -64,13 +65,17 @@ final class PaykitPaymentActivityTests: XCTestCase { let request = try XCTUnwrap(state.request(session: session, isActive: true)) var refreshes = 0 let work = Task { - try await activity.waitUntilIdle() + try await activity.waitUntilIdle(includingReviews: true) refreshes += 1 state.complete(request) } await Task.yield() XCTAssertEqual(refreshes, 0) activity.end(payment) + await Task.yield() + XCTAssertEqual(refreshes, 0) + XCTAssertEqual(state.request(session: session, isActive: true), request) + activity.endReview(review) try await work.value XCTAssertEqual(refreshes, 1) XCTAssertNil(state.request(session: session, isActive: true)) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 432348345..aeafa9235 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -364,31 +364,35 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testFreshRefreshRereadsRequestsChangedAfterSnapshot() async throws { - let record = try paymentRequestRecord() - let sdk = PaymentRequestSdkMock(records: [record]) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh(messagePriority: .background) } - try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background] + for refreshPriority in refreshPriorities { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh(messagePriority: .background) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } - await sdk.setRecords([]) - let refreshStarted = expectation(description: "Request state refresh started") - let fresh = Task { - refreshStarted.fulfill() - await manager.refresh(forceFresh: true, messagePriority: .background) - } - await fulfillment(of: [refreshStarted], timeout: 1) - await sdk.resumePaymentRequestList() - _ = await first.value - await fresh.value + await sdk.setRecords([]) + let refreshStarted = expectation(description: "Request state refresh started") + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, messagePriority: .background, refreshPriority: refreshPriority) + } + await fulfillment(of: [refreshStarted], timeout: 1) + await sdk.resumePaymentRequestList() + _ = await first.value + await fresh.value - XCTAssertTrue(manager.pendingRequests.isEmpty) - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) - let priorities = await sdk.operationPriorities - XCTAssertEqual(priorities["pending"], [.background, .ordered]) - XCTAssertEqual(priorities["receive"], [.background, .ordered]) - XCTAssertEqual(priorities["observation"], [.background, .ordered]) + XCTAssertTrue(manager.pendingRequests.isEmpty) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 2) + let priorities = await sdk.operationPriorities + let expectedPriority = refreshPriority ?? .ordered + XCTAssertEqual(priorities["pending"], [.background, expectedPriority]) + XCTAssertEqual(priorities["receive"], [.background, expectedPriority]) + XCTAssertEqual(priorities["observation"], [.background, expectedPriority]) + } } func testFreshRefreshRereadsProofStateChangedDuringNotificationSynchronization() async throws { @@ -503,31 +507,34 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testFreshRefreshDoesNotOutliveCancellationOrIdentityChange() async throws { - for invalidation in ["cancel", "clear", "identity"] { - let sdk = PaymentRequestSdkMock(records: []) - let manager = paymentRequestManager(sdk: sdk) - await sdk.pauseNextPaymentRequestList() - let first = Task { await manager.refresh() } - try await waitUntil { await sdk.paymentRequestListIsPaused() } - let refreshStarted = expectation(description: invalidation) - let fresh = Task { - refreshStarted.fulfill() - await manager.refresh(forceFresh: true) - } - await fulfillment(of: [refreshStarted], timeout: 1) + let refreshPriorities: [PaykitSdkOperationLock.Priority?] = [nil, .background] + for refreshPriority in refreshPriorities { + for invalidation in ["cancel", "clear", "identity"] { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextPaymentRequestList() + let first = Task { await manager.refresh() } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let refreshStarted = expectation(description: invalidation) + let fresh = Task { + refreshStarted.fulfill() + await manager.refresh(forceFresh: true, refreshPriority: refreshPriority) + } + await fulfillment(of: [refreshStarted], timeout: 1) - switch invalidation { - case "cancel": fresh.cancel() - case "clear": manager.clear() - default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") - } - await sdk.resumePaymentRequestList() - _ = await first.value - await fresh.value + switch invalidation { + case "cancel": fresh.cancel() + case "clear": manager.clear() + default: manager.activate(identity: "pubky\(String(repeating: "y", count: 52))") + } + await sdk.resumePaymentRequestList() + _ = await first.value + await fresh.value - let snapshot = await sdk.snapshot() - XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) - XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 1, invalidation) + XCTAssertTrue(manager.pendingRequests.isEmpty, invalidation) + } } } @@ -4170,6 +4177,48 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) } + func testBackgroundStateChangeRefreshDefersWithoutConsumingSnapshotOrBlockingRecovery() async throws { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk) + let loaded = await manager.refresh(mode: .stored) + XCTAssertTrue(loaded) + let pending = manager.pendingRequests + await sdk.pauseNextPaymentRequestList() + let admitted = Task { await manager.refresh(mode: .stored) } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + let started = expectation(description: "Proof display refresh waiting for admitted snapshot") + let fresh = Task { + started.fulfill() + return await manager.refresh(mode: .stored, forceFresh: true, refreshPriority: .background) + } + await fulfillment(of: [started], timeout: 1) + await sdk.setBackgroundObservationDeferred(true) + await sdk.setRecords([]) + await sdk.resumePaymentRequestList() + let admittedResult = await admitted.value + let deferredResult = await fresh.value + XCTAssertTrue(admittedResult) + XCTAssertFalse(deferredResult) + XCTAssertEqual(manager.pendingRequests, pending) + + let recovered = await manager.refresh(mode: .stored, forceFresh: true) + XCTAssertTrue(recovered) + XCTAssertTrue(manager.pendingRequests.isEmpty) + await sdk.setBackgroundObservationDeferred(false) + let latest = try paymentRequestRecord(id: "latest-request") + await sdk.setRecords([latest]) + let retried = await manager.refresh(mode: .stored) + XCTAssertTrue(retried) + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [latest.paymentRequestId]) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["observation"], [.background, .background, .background, .ordered, .background]) + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.paymentRequestListCallCount, 4) + XCTAssertEqual(snapshot.processCallCount, 0) + XCTAssertEqual(snapshot.receiveCallCount, 0) + } + func testRefreshContinuesWhenPendingResponseDeliveryFails() async throws { let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) await sdk.failNextProcess() @@ -9360,6 +9409,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var incomingRecords: [PaymentRequestRecord] = [] private var paymentRequestListCallCount = 0 private var paymentRequestListError: Error? + private var isBackgroundObservationDeferred = false private var peerRecords: [LinkedPeerRecord] = [] private var requestCapabilitiesByPublicKey: [String: Bool] = [:] private var liveSessionAvailable = true @@ -9488,6 +9538,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym func observeState(priority: PaykitSdkOperationLock.Priority, expectedIdentity: String?) async throws -> PaykitStateObservation { operationPriorities["observation", default: []].append(priority) + if priority == .background, isBackgroundObservationDeferred { + throw PaykitPaymentRequestError.operationInProgress + } let identity = activeIdentity if let expectedIdentity { guard PubkyPublicKeyFormat.matches(identity, expectedIdentity) else { throw PubkyServiceError.identityChanged } @@ -9534,6 +9587,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym paymentRequestListError = error } + func setBackgroundObservationDeferred(_ deferred: Bool) { + isBackgroundObservationDeferred = deferred + } + func identityStatus() -> IdentityStatus? { IdentityStatus(publicKey: activeIdentity, capability: liveSessionAvailable ? .privateLinkCapable : .signedOut) } diff --git a/changelog.d/next/proof-display-admission.fixed.md b/changelog.d/next/proof-display-admission.fixed.md new file mode 100644 index 000000000..7585296c7 --- /dev/null +++ b/changelog.d/next/proof-display-admission.fixed.md @@ -0,0 +1 @@ +Background payment-request updates now give priority to payment review and submission. From 6153563f7f73b44ed1a5f99c8606a3fab89e2bf4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 17:39:48 -0600 Subject: [PATCH 2/3] chore: rename changelog fragment --- .../next/{proof-display-admission.fixed.md => 1066.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{proof-display-admission.fixed.md => 1066.fixed.md} (100%) diff --git a/changelog.d/next/proof-display-admission.fixed.md b/changelog.d/next/1066.fixed.md similarity index 100% rename from changelog.d/next/proof-display-admission.fixed.md rename to changelog.d/next/1066.fixed.md From c8bab77fa30fa4084aee77db3602a900796c5565 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 23 Oct 2026 09:04:48 +0200 Subject: [PATCH 3/3] fix: skip allowance work when request refresh defers --- Bitkit/AppScene.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 537f279e2..1db93432c 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1488,6 +1488,7 @@ struct AppScene: View { mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, refreshPriority: refreshPriority, canRefresh: canRefresh ) + guard refreshed else { return false } // Deleting the profile withdraws the payment lists an automatic payment would consume. if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile { await paykitAllowanceManager.refresh()