From 6dd46e465410e1e7fa87c9dd8e1c440eaa34ce4f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 21 Oct 2026 07:47:27 -0600 Subject: [PATCH] fix: avoid inbox waits after preparation errors --- .../PaykitPaymentRequestService.swift | 6 +- .../PaykitPaymentRequestServiceTests.swift | 81 +++++++++++++++++-- changelog.d/next/1081.fixed.md | 1 + 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 changelog.d/next/1081.fixed.md diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 02c390eeb..407a8de9e 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1956,7 +1956,8 @@ final class PaykitPaymentRequestManager { request, resultingState: .accepted, markApprovedForPayment: true, - preservePending: !request.requiresAcceptance + preservePending: !request.requiresAcceptance, + failureRefreshMode: .stored ) { guard $0.billingPeriod != nil || $0.requiresAcceptance || acceptedRequestIds.contains($0.id) else { throw PaykitPaymentRequestError.requestUnavailable @@ -2714,6 +2715,7 @@ final class PaykitPaymentRequestManager { resultingState: Paykit.PaymentRequestLifecycleState, markApprovedForPayment: Bool = false, preservePending: Bool = false, + failureRefreshMode: PaykitPaymentRequestRefreshMode = .full, operation: (PaykitPaymentRequest) async throws -> Void ) async throws { guard !request.isExpired(at: now()) else { @@ -2764,7 +2766,7 @@ final class PaykitPaymentRequestManager { } catch { guard actionGeneration == stateGeneration else { throw error } invalidateRefresh() - await refresh(excludingProtectedRequestId: request.id, forceFresh: true) + await refresh(excludingProtectedRequestId: request.id, mode: failureRefreshMode, forceFresh: true) throw error } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2a540aa94..586c71990 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -6095,6 +6095,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) await sdk.failNextAcceptAfterRemoval() + await sdk.setReceiveError(.process) + let beforeFailure = await sdk.snapshot() do { try await manager.prepareForPayment(request) @@ -6105,6 +6107,57 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertTrue(manager.requestsForPresentation().isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) + let afterFailure = await sdk.snapshot() + XCTAssertEqual(afterFailure.processCallCount, beforeFailure.processCallCount) + XCTAssertEqual(afterFailure.receiveCallCount, beforeFailure.receiveCallCount) + XCTAssertEqual(afterFailure.paymentRequestListCallCount, beforeFailure.paymentRequestListCallCount + 1) + + let firstFullRefresh = await manager.refresh() + XCTAssertFalse(firstFullRefresh, "The inbox transport failure belongs to the next full refresh") + let afterFullRefresh = await sdk.snapshot() + XCTAssertEqual(afterFullRefresh.processCallCount, beforeFailure.processCallCount + 1) + await sdk.setReceiveError(nil) + let next = try paymentRequestRecord(id: "next") + await sdk.setIncomingRecords([next]) + let recovered = await manager.refresh() + XCTAssertTrue(recovered) + XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), [next.paymentRequestId]) + } + + func testPreparationFailureRefreshKeepsProcessingGuardAndRejectsReplacementIdentity() async throws { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh() + let request = try XCTUnwrap(manager.pendingRequests.first) + await sdk.pauseNextPaymentRequestList() + let preparation = Task { + try await manager.prepareForPayment(request) { + throw PaymentRequestSdkMockError.preparation + } + } + try await waitUntil { await sdk.paymentRequestListIsPaused() } + XCTAssertFalse(manager.requestPresentation(request)) + do { + try await manager.prepareForPayment(request) + XCTFail("A pending failure refresh must keep the request in flight") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .operationInProgress) + } + let replacementIdentity = "pubky\(String(repeating: "y", count: 52))" + manager.clear() + manager.activate(identity: replacementIdentity) + await sdk.setActiveIdentity(replacementIdentity) + await sdk.resumePaymentRequestList() + do { + try await preparation.value + XCTFail("Expected the original preparation failure") + } catch { + XCTAssertEqual(error as? PaymentRequestSdkMockError, .preparation) + } + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertTrue(manager.historyRequests.isEmpty) + XCTAssertFalse(manager.isApprovedForPayment(request)) } func testFailedRejectionDropsRequestRemovedFromAuthoritativeQueue() async throws { @@ -6113,6 +6166,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) await sdk.failNextRejectAfterRemoval() + let beforeFailure = await sdk.snapshot() do { try await manager.reject(request) @@ -6123,6 +6177,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.pendingRequests.isEmpty) XCTAssertTrue(manager.requestsForPresentation().isEmpty) + let afterFailure = await sdk.snapshot() + XCTAssertEqual(afterFailure.processCallCount, beforeFailure.processCallCount + 1) + XCTAssertEqual(afterFailure.receiveCallCount, beforeFailure.receiveCallCount + 1) } func testInterruptedPreparationRemainsImmediatelyPresentableAfterForegroundOrUnlock() async throws { @@ -7239,16 +7296,25 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = paymentRequestManager(sdk: sdk, acceptanceStore: store) await manager.refresh() let request = try XCTUnwrap(manager.pendingRequests.first) - await sdk.setAcceptanceResponseError(error) + var accepted = record + accepted.state = .accepted + accepted.acceptedEventId = "accepted" + accepted.executionClaimAppId = "bitkit" + await sdk.setAcceptanceResponseError(error, committedRecord: accepted) + let beforeFailure = await sdk.snapshot() do { try await manager.prepareForPayment(request) XCTFail("The interrupted call must not authorize execution") } catch {} XCTAssertFalse(manager.isApprovedForPayment(request)) - var accepted = record - accepted.state = .accepted - await sdk.setRecords([accepted]) + XCTAssertEqual(try store.load(identity: "pubky\(String(repeating: "z", count: 52))"), [request.id]) + XCTAssertEqual(manager.pendingRequests.map(\.id), [request.id]) + XCTAssertEqual(manager.historyRequests.first?.lifecycleState, .accepted) + let afterFailure = await sdk.snapshot() + XCTAssertEqual(afterFailure.processCallCount, beforeFailure.processCallCount) + XCTAssertEqual(afterFailure.receiveCallCount, beforeFailure.receiveCallCount) + XCTAssertEqual(afterFailure.paymentRequestListCallCount, beforeFailure.paymentRequestListCallCount + 1) let restarted = paymentRequestManager(sdk: sdk, acceptanceStore: store) await restarted.refresh() let retry = try XCTUnwrap(restarted.pendingRequests.first) @@ -9527,6 +9593,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym private var rejectedRequests: [PaymentRequestInvocation] = [] private var acceptFailuresAfterRemoval = 0 private var acceptanceResponseError: Error? + private var acceptanceCommittedRecord: PaymentRequestRecord? private var rejectFailuresAfterRemoval = 0 private var proposedRequests: [ProposedPaymentRequestInvocation] = [] @@ -9849,6 +9916,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym ) } if let acceptanceResponseError { + if let acceptanceCommittedRecord { + records.append(acceptanceCommittedRecord) + } throw acceptanceResponseError } if acceptFailuresAfterRemoval > 0 { @@ -9905,8 +9975,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym processFailuresRemaining += 1 } - func setAcceptanceResponseError(_ error: Error) { + func setAcceptanceResponseError(_ error: Error, committedRecord: PaymentRequestRecord? = nil) { acceptanceResponseError = error + acceptanceCommittedRecord = committedRecord } func pauseNextProcess(for counterparty: String? = nil) { diff --git a/changelog.d/next/1081.fixed.md b/changelog.d/next/1081.fixed.md new file mode 100644 index 000000000..23cb9e875 --- /dev/null +++ b/changelog.d/next/1081.fixed.md @@ -0,0 +1 @@ +Keep payment request state fresh after preparation failures without waiting for message delivery.