From b1d65a7f0787717f03cb59a8922610a017113ed1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 20 Oct 2026 20:04:22 -0600 Subject: [PATCH 1/4] fix: keep Paykit inbox observation together --- Bitkit/AppScene.swift | 2 +- .../PaykitPaymentRequestService.swift | 41 +++- Bitkit/Services/PubkyService.swift | 108 +++++--- .../PaykitPaymentRequestServiceTests.swift | 132 ++++++++-- BitkitTests/PaykitSdkOperationLockTests.swift | 230 +++++++++++++++++- changelog.d/next/1070.fixed.md | 1 + journeys/payment-requests/README.md | 13 + .../automatic-presentation.xml | 2 + journeys/payment-requests/review-refresh.xml | 4 +- 9 files changed, 472 insertions(+), 61 deletions(-) create mode 100644 changelog.d/next/1070.fixed.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index a0163e0bf..725bf2fc4 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -679,7 +679,7 @@ struct AppScene: View { guard isPaykitSceneActive, pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(pk, pubkyProfile.publicKey) else { return } - await refreshIncomingPaykitPaymentRequests(forceFresh: true, onlyWhileActive: true) + await refreshIncomingPaykitPaymentRequests(forceFresh: true, refreshPriority: .background, onlyWhileActive: true) } } .onReceive(PrivatePaykitService.contactLinkCompletedPublisher.receive(on: DispatchQueue.main)) { completion in diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 42eb3ff98..02c390eeb 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -605,6 +605,10 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable { protocol PaykitPaymentRequestSdkHandling: Sendable { func observeState(priority: PaykitSdkOperationLock.Priority, expectedIdentity: String?) async throws -> Paykit.PaykitStateObservation + func receiveAndObserveState( + expectedIdentity: String, + onReceive: @Sendable ([Paykit.PrivateStreamCounterpartyIntakeReport]) -> Date + ) async throws -> (observation: Paykit.PaykitStateObservation, synchronizationDate: Date) func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport func processOutboundPrivateMessages(counterparty: String, priority: PaykitSdkOperationLock.Priority) async throws -> Paykit .OutboundPrivateSendReport @@ -694,17 +698,29 @@ struct PaykitPaymentRequestService { mode: PaykitPaymentRequestRefreshMode = .full, priority: PaykitSdkOperationLock.Priority = .ordered, messagePriority: PaykitSdkOperationLock.Priority = .ordered, - expectedIdentity: String? = nil + expectedIdentity: String? = nil, + combineReceiveObservation: Bool = false ) async throws -> PaykitPaymentRequestSnapshot { if mode == .full { try await processPendingMessages(priority: messagePriority) } - if mode >= .inbox { - let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers(priority: messagePriority) - logIntakeFailures(intakeReports) + let observation: Paykit.PaykitStateObservation + let synchronizationDate: Date + if combineReceiveObservation, mode >= .inbox, priority == .background, messagePriority == .background, let expectedIdentity { + let received = try await sdk.receiveAndObserveState(expectedIdentity: expectedIdentity) { reports in + logIntakeFailures(reports) + return now() + } + observation = received.observation + synchronizationDate = received.synchronizationDate + } else { + if mode >= .inbox { + let intakeReports = try await sdk.receivePrivateMessagesFromLinkedPeers(priority: messagePriority) + logIntakeFailures(intakeReports) + } + synchronizationDate = now() + observation = try await sdk.observeState(priority: priority, expectedIdentity: expectedIdentity) } - let synchronizationDate = now() - let observation = try await sdk.observeState(priority: priority, expectedIdentity: expectedIdentity) let sharedRecords = observation.paymentRequests let records = sharedRecords.filter(PaykitSdkService.isBitkitPaymentRequest) let blockedPeers = observation.linkedPeers.filter { $0.state == .blocked } @@ -1811,6 +1827,7 @@ final class PaykitPaymentRequestManager { mode: mode, forceFresh: forceFresh, messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority), + combineReceiveObservation: !forceFresh, canRefresh: canRefresh ) } @@ -1839,6 +1856,7 @@ final class PaykitPaymentRequestManager { mode: PaykitPaymentRequestRefreshMode = .full, forceFresh: Bool = false, messagePriority: PaykitSdkOperationLock.Priority = .ordered, + combineReceiveObservation: Bool = false, canRefresh: @escaping @MainActor () -> Bool = { true } ) async -> Bool { guard canRefresh() else { return false } @@ -1849,7 +1867,7 @@ final class PaykitPaymentRequestManager { if forceFresh || mode > refreshTask.mode { return await refresh( excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, - messagePriority: messagePriority, canRefresh: canRefresh + messagePriority: messagePriority, combineReceiveObservation: combineReceiveObservation, canRefresh: canRefresh ) } return refreshed @@ -1869,7 +1887,8 @@ final class PaykitPaymentRequestManager { generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, - messagePriority: messagePriority + messagePriority: messagePriority, + combineReceiveObservation: combineReceiveObservation ) guard generation == refreshGeneration else { return false } return refreshed @@ -2494,12 +2513,14 @@ final class PaykitPaymentRequestManager { generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID?, mode: PaykitPaymentRequestRefreshMode, - messagePriority: PaykitSdkOperationLock.Priority + messagePriority: PaykitSdkOperationLock.Priority, + combineReceiveObservation: Bool ) async -> Bool { do { guard let activeIdentity else { return false } let snapshot = try await service.synchronize( - mode: mode, priority: messagePriority, messagePriority: messagePriority, expectedIdentity: activeIdentity + mode: mode, priority: messagePriority, messagePriority: messagePriority, expectedIdentity: activeIdentity, + combineReceiveObservation: combineReceiveObservation ) guard generation == refreshGeneration, PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity) else { return false } async let completedProofKinds = completedPaymentProofKinds(activeIdentity) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 1fd0a3b44..304293a30 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1077,6 +1077,48 @@ actor PaykitSdkService { } } + func receiveAndObserveState( + expectedIdentity: String, + onReceive: @Sendable ([PrivateStreamCounterpartyIntakeReport]) -> Date + ) async throws -> (observation: Paykit.PaykitStateObservation, synchronizationDate: Date) { + let generation = runtimeGeneration + let walletGeneration = try operationLock.walletGeneration() + let checkAdmission = { + try Task.checkCancellation() + guard self.runtimeGeneration == generation, try self.operationLock.walletGeneration() == walletGeneration else { + throw PubkyServiceError.identityChanged + } + try await self.checkRefreshAdmission(.background) + try Task.checkCancellation() + guard self.runtimeGeneration == generation, try self.operationLock.walletGeneration() == walletGeneration else { + throw PubkyServiceError.identityChanged + } + } + return try await withSdk(priority: .background, beforeOperation: checkAdmission) { sdk in + let key = self.cachedPaykitKey + if let key { + guard PubkyPublicKeyFormat.matches(key.publicKey, expectedIdentity) else { throw PubkyServiceError.identityChanged } + } + let reports = try await self.withStateRevisionTracking(sdk: sdk, beforeOperation: checkAdmission) { + try await sdk.receivePrivateMessagesFromLinkedPeers() + } + let synchronizationDate = onReceive(reports) + return try await self.operationLock.withoutLock { + try await checkAdmission() + guard self.sdk === sdk, self.cachedPaykitKey?.publicKey == key?.publicKey, + self.cachedPaykitKey?.generation == key?.generation + else { throw PubkyServiceError.identityChanged } + try Task.checkCancellation() + let observation = try await sdk.observeState() + try Task.checkCancellation() + guard PubkyPublicKeyFormat.matches(observation.publicKey, expectedIdentity), + key == nil || observation.keyGeneration == key?.generation + else { throw PubkyServiceError.identityChanged } + return (observation, synchronizationDate) + } + } + } + @discardableResult func receivePrivateMessages(counterparty: String) async throws -> PrivateStreamIntakeReport { try await receivePrivateMessages(counterparty: counterparty, priority: .ordered) @@ -1559,38 +1601,46 @@ actor PaykitSdkService { try await withSdk( priority: priority, deferDuringPayment: deferDuringPayment, operationName: operationName, beforeOperation: beforeOperation ) { sdk in - return try await Self.withBackupStateRevisionTracking( - readRevision: { - #if DEBUG - let clock = ContinuousClock() - let started = clock.now - defer { - Logger.debug("Read Paykit backup revision duration=\(started.duration(to: clock.now))", context: "PaykitSdkService") - } - #endif - return try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } - }, - readStateRevision: { try sdk.stateRevision() }, - readObservedSnapshot: { - try sdk.observedBackupStateRevision().map { - BackupStateSnapshot(stateRevision: $0.stateRevision, backupRevision: $0.backupRevision) - } - }, - cachedSnapshot: self.cachedBackupState, - onSnapshot: { self.cachedBackupState = $0 }, - onChange: { self.markWalletBackupDataChanged() }, - beforeOperation: { - try await beforeOperation() - if deferDuringPayment { - guard await !PaykitPaymentActivity.shared.isActive else { throw OperationDeferral.paymentActive } - try Task.checkCancellation() - } - }, - operation: { try await operation(sdk) } - ) + try await self.withStateRevisionTracking(sdk: sdk, beforeOperation: { + try await beforeOperation() + if deferDuringPayment { + guard await !PaykitPaymentActivity.shared.isActive else { throw OperationDeferral.paymentActive } + try Task.checkCancellation() + } + }) { try await operation(sdk) } } } + private func withStateRevisionTracking( + sdk: PaykitSdk, + beforeOperation: () async throws -> Void, + _ operation: () async throws -> T + ) async throws -> T { + return try await Self.withBackupStateRevisionTracking( + readRevision: { + #if DEBUG + let clock = ContinuousClock() + let started = clock.now + defer { + Logger.debug("Read Paykit backup revision duration=\(started.duration(to: clock.now))", context: "PaykitSdkService") + } + #endif + return try await self.withSdkErrorHandling { try await sdk.backupStateRevision() } + }, + readStateRevision: { try sdk.stateRevision() }, + readObservedSnapshot: { + try sdk.observedBackupStateRevision().map { + BackupStateSnapshot(stateRevision: $0.stateRevision, backupRevision: $0.backupRevision) + } + }, + cachedSnapshot: cachedBackupState, + onSnapshot: { self.cachedBackupState = $0 }, + onChange: { self.markWalletBackupDataChanged() }, + beforeOperation: beforeOperation, + operation: operation + ) + } + static func withBackupStateRevisionTracking( readRevision: () async throws -> String, readStateRevision: () throws -> String? = { nil }, diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index aeafa9235..2a540aa94 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -219,10 +219,51 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(priorities["observation"], [priority], context) XCTAssertNil(priorities["requests"], context) XCTAssertNil(priorities["peers"], context) + XCTAssertEqual(priorities["inbox"] ?? [], mode >= .inbox && priority == .background ? [.background] : [], context) } } } + func testForcedAndNonBackgroundRefreshRetainSeparateReceiveAndObservation() async { + for mode in [PaykitPaymentRequestRefreshMode.inbox, .full] { + for priority in [PaykitSdkOperationLock.Priority.background, .ordered, .interactive] { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await manager.refresh(mode: mode, forceFresh: priority == .background, refreshPriority: priority) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["receive"], [priority]) + XCTAssertEqual(priorities["observation"], [priority]) + XCTAssertNil(priorities["inbox"]) + } + } + } + + func testAutomaticFullRefreshFinishesPendingDrainBeforeObservationAndStopsOnDeferral() async throws { + for deferred in [false, true] { + let sdk = PaymentRequestSdkMock(records: []) + let manager = paymentRequestManager(sdk: sdk) + await sdk.pauseNextProcess() + if deferred { + await sdk.failNextProcess(with: PaykitPaymentRequestError.operationInProgress) + } + let refresh = Task { await manager.refresh(mode: .full) } + try await waitUntil { await sdk.processIsPaused() } + let paused = await sdk.operationPriorities + XCTAssertEqual(paused["pending"], [.background]) + XCTAssertNil(paused["inbox"]) + XCTAssertNil(paused["receive"]) + XCTAssertNil(paused["observation"]) + await sdk.resumeProcess() + let result = await refresh.value + let priorities = await sdk.operationPriorities + XCTAssertEqual(result, !deferred) + XCTAssertEqual(priorities["pending"], [.background]) + XCTAssertEqual(priorities["inbox"] ?? [], deferred ? [] : [.background]) + XCTAssertEqual(priorities["receive"] ?? [], deferred ? [] : [.background]) + XCTAssertEqual(priorities["observation"] ?? [], deferred ? [] : [.background]) + } + } + func testOverlappingRefreshesUpgradeOnlyForStrongerModes() async throws { let modes: [PaykitPaymentRequestRefreshMode] = [.stored, .inbox, .full] for initialMode in modes { @@ -4156,25 +4197,29 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testDeferredRefreshPreservesRequestsAndAllowsRecoveryAndRetry() async throws { - let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) - let manager = paymentRequestManager(sdk: sdk) - let loaded = await manager.refresh(mode: .stored) - XCTAssertTrue(loaded) - let pending = manager.pendingRequests - await sdk.failNextProcess(with: PaykitPaymentRequestError.operationInProgress) - let deferred = await manager.refresh() - XCTAssertFalse(deferred) - XCTAssertEqual(manager.pendingRequests, pending) - let skipped = await sdk.snapshot() - XCTAssertEqual(skipped.receiveCallCount, 0) - let recovered = await manager.refresh(forceFresh: true) - XCTAssertTrue(recovered) - let priorities = await sdk.operationPriorities - XCTAssertEqual(priorities["observation"], [.background, .ordered]) - await sdk.setRecords([]) - let retried = await manager.refresh() - XCTAssertTrue(retried) - XCTAssertTrue(manager.pendingRequests.isEmpty) + for forceFresh in [false, true] { + let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord()]) + let manager = paymentRequestManager(sdk: sdk) + let loaded = await manager.refresh(mode: .stored) + XCTAssertTrue(loaded) + let pending = manager.pendingRequests + await sdk.failNextProcess(with: PaykitPaymentRequestError.operationInProgress) + let deferred = await manager.refresh(forceFresh: forceFresh, refreshPriority: forceFresh ? .background : nil) + XCTAssertFalse(deferred) + XCTAssertEqual(manager.pendingRequests, pending) + let skipped = await sdk.snapshot() + XCTAssertEqual(skipped.receiveCallCount, 0) + let deferredPriorities = await sdk.operationPriorities + XCTAssertEqual(deferredPriorities["pending"], [.background]) + let recovered = await manager.refresh(forceFresh: true) + XCTAssertTrue(recovered) + let priorities = await sdk.operationPriorities + XCTAssertEqual(priorities["observation"], [.background, .ordered]) + await sdk.setRecords([]) + let retried = await manager.refresh() + XCTAssertTrue(retried) + XCTAssertTrue(manager.pendingRequests.isEmpty) + } } func testBackgroundStateChangeRefreshDefersWithoutConsumingSnapshotOrBlockingRecovery() async throws { @@ -4307,6 +4352,44 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testAutomaticInboxKeepsIntakeDiagnosticsAndTimeBeforeFailedObservation() async throws { + for deferred in [false, true] { + let record = try paymentRequestRecord() + let sdk = PaymentRequestSdkMock(records: [record]) + await sdk.setReceiveReports([ + PrivateStreamCounterpartyIntakeReport( + counterparty: record.counterparty, report: nil, error: PaymentRequestIntakeError(noPointer: .init()) + ), + ]) + if deferred { + await sdk.setBackgroundObservationDeferred(true) + } else { + await sdk.setLinkedPeersError(.linkedPeers) + } + let recorder = PaymentRequestLogRecorder() + let service = PaykitPaymentRequestService(sdk: sdk, now: { + recorder.append("synchronization-date") + return Date() + }, logWarning: { recorder.append($0) }) + do { + _ = try await service.synchronize( + mode: .inbox, priority: .background, messagePriority: .background, + expectedIdentity: "pubky\(String(repeating: "z", count: 52))", combineReceiveObservation: true + ) + XCTFail("Expected observation failure") + } catch PaykitPaymentRequestError.operationInProgress { + XCTAssertTrue(deferred) + } catch PaymentRequestSdkMockError.linkedPeers { + XCTAssertFalse(deferred) + } + XCTAssertEqual(recorder.messages.count, 2) + XCTAssertTrue(recorder.messages[0].hasPrefix("Failed to receive Paykit private messages")) + XCTAssertEqual(recorder.messages[1], "synchronization-date") + let snapshot = await sdk.snapshot() + XCTAssertEqual(snapshot.receiveCallCount, 1) + } + } + func testPeerIntakeFailureDoesNotDropReceivedRequests() async throws { let record = try paymentRequestRecord() let sdk = PaymentRequestSdkMock(records: [record]) @@ -9523,6 +9606,17 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym return receiveReports } + func receiveAndObserveState( + expectedIdentity: String, + onReceive: @Sendable ([PrivateStreamCounterpartyIntakeReport]) -> Date + ) async throws -> (observation: PaykitStateObservation, synchronizationDate: Date) { + operationPriorities["inbox", default: []].append(.background) + let reports = try receivePrivateMessagesFromLinkedPeers(priority: .background) + let synchronizationDate = onReceive(reports) + let observation = try await observeState(priority: .background, expectedIdentity: expectedIdentity) + return (observation, synchronizationDate) + } + func readPaymentRequests(counterparty: String, expectedIdentity: String?) async -> PaymentRequestRead { let status = identityStatus() guard status?.capability == .privateLinkCapable, diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift index 422aa319b..56ba56010 100644 --- a/BitkitTests/PaykitSdkOperationLockTests.swift +++ b/BitkitTests/PaykitSdkOperationLockTests.swift @@ -68,6 +68,225 @@ final class PaykitSdkOperationLockTests: XCTestCase { } } + func testInboxObservationPrecedesQueuedPublicationAndTracksOnlyReceive() async throws { + for changed in [false, true] { + for observeFails in [false, true] { + let sdk = PublicReadSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let recorder = Recorder() + let gate = AsyncGate() + defer { gate.open() } + let started = expectation(description: "Receive started") + let received = expectation(description: "Receive reported once") + received.assertForOverFulfill = true + let identity = String(repeating: "y", count: 52) + let synchronizationDate = Date(timeIntervalSince1970: 1_800_000_000) + sdk.inboxReceive = { + await recorder.record("receive") + started.fulfill() + await gate.wait() + if changed { + sdk.backupRevision = "changed" + } + return [] + } + sdk.inboxObservation = { + await recorder.record("observe") + let version = await service.backupStateVersion + XCTAssertEqual(version, changed ? 1 : 0) + if observeFails { + throw PaymentMutationReached.reached + } + return PaykitStateObservation( + publicKey: identity, keyGeneration: 1, observedAt: "2027-01-15T08:00:00Z", + contacts: [], linkedPeers: [], paymentRequests: [], receipts: [] + ) + } + sdk.publication = { _ in await recorder.record("publish") } + let refresh = Task { + try await service.receiveAndObserveState(expectedIdentity: identity) { reports in + XCTAssertTrue(reports.isEmpty) + received.fulfill() + return synchronizationDate + } + } + await fulfillment(of: [started], timeout: 2) + let publication = Task { try await service.syncPaykitApp(privatePaymentsEnabled: false, priority: .background) } + try await Task.sleep(for: .milliseconds(50)) + let heldEvents = await recorder.events + XCTAssertEqual(heldEvents, ["receive"]) + gate.open() + do { + let result = try await refresh.value + XCTAssertFalse(observeFails) + XCTAssertEqual(result.observation.publicKey, identity) + XCTAssertEqual(result.synchronizationDate, synchronizationDate) + } catch PaymentMutationReached.reached { + XCTAssertTrue(observeFails) + } + try await publication.value + await fulfillment(of: [received], timeout: 2) + let events = await recorder.events + let version = await service.backupStateVersion + XCTAssertEqual(events, ["receive", "observe", "publish"]) + XCTAssertEqual(version, changed ? 1 : 0) + } + } + } + + @MainActor + func testInboxObservationYieldsAfterReceiveToPaymentReviewCancellationAndWipe() async throws { + for boundary in ["payment", "review", "cancellation", "wipe"] { + let sdk = PublicReadSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let recorder = Recorder() + let gate = AsyncGate() + defer { gate.open() } + let started = expectation(description: "Receive started") + let received = expectation(description: "Receive reported once") + received.assertForOverFulfill = true + sdk.inboxReceive = { + await recorder.record("receive") + started.fulfill() + await gate.wait() + sdk.backupRevision = "changed" + return [] + } + sdk.inboxObservation = { + XCTFail("Observation must yield at the second boundary") + throw PaymentMutationReached.reached + } + sdk.lockedRead = { + XCTAssertEqual(sdk.backupRevision, "changed") + await recorder.record("authority") + } + let refresh = Task { + try await service.receiveAndObserveState(expectedIdentity: String(repeating: "y", count: 52)) { _ in + received.fulfill() + return Date() + } + } + await fulfillment(of: [started], timeout: 2) + let payment = boundary == "payment" ? PaykitPaymentActivity.shared.begin() : nil + let review = boundary == "review" ? PaykitPaymentActivity.shared.beginReview() : nil + defer { + if let payment { + PaykitPaymentActivity.shared.end(payment) + } + if let review { + PaykitPaymentActivity.shared.endReview(review) + } + } + if boundary == "cancellation" { + refresh.cancel() + } + let wipe = boundary == "wipe" ? Task { try await service.withWalletWipe {} } : nil + if wipe != nil { + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while await (try? service.backupIdentity()) != nil, ContinuousClock.now < deadline { + await Task.yield() + } + let identity = try? await service.backupIdentity() + XCTAssertNil(identity, "Wipe must close admission before receive resumes") + } + let authority = wipe == nil ? Task { try await service.contactRecords() } : nil + gate.open() + do { + _ = try await refresh.value + XCTFail("Expected the refresh to stop after receive") + } catch is CancellationError { + XCTAssertEqual(boundary, "cancellation") + } catch PaykitPaymentRequestError.operationInProgress { + XCTAssertTrue(["payment", "review"].contains(boundary)) + } catch let PaykitError.Storage(code, _) { + XCTAssertEqual(boundary, "wipe") + XCTAssertEqual(code, "wallet_wipe_in_progress") + } + try await wipe?.value + _ = try await authority?.value + await fulfillment(of: [received], timeout: 2) + let events = await recorder.events + let version = await service.backupStateVersion + XCTAssertEqual(events, boundary == "wipe" ? ["receive"] : ["receive", "authority"]) + XCTAssertEqual(version, 1) + } + } + + func testInboxReceiveFailureDoesNotObserveOrReplay() async throws { + let sdk = PublicReadSdk(noPointer: .init()) + let recorder = Recorder() + sdk.inboxReceive = { + await recorder.record("receive") + throw PaymentMutationReached.reached + } + sdk.inboxObservation = { + XCTFail("A failed receive must not be observed") + throw PaymentMutationReached.reached + } + let service = PaykitSdkService(sdkFactory: { sdk }) + do { + _ = try await service.receiveAndObserveState(expectedIdentity: String(repeating: "y", count: 52)) { _ in + XCTFail("A failed receive has no report") + return Date() + } + XCTFail("Expected receive failure") + } catch PaymentMutationReached.reached {} + let events = await recorder.events + let version = await service.backupStateVersion + XCTAssertEqual(events, ["receive"]) + XCTAssertEqual(version, 1) + } + + func testInboxRefreshRejectsQueuedRuntimeReplacementAndAnotherObservedIdentity() async throws { + let sdk = PublicReadSdk(noPointer: .init()) + let service = PaykitSdkService(sdkFactory: { sdk }) + let gate = AsyncGate() + defer { gate.open() } + let started = expectation(description: "Holder started") + sdk.lockedRead = { + started.fulfill() + await gate.wait() + } + sdk.inboxReceive = { + XCTFail("A replaced queued runtime must not receive") + return [] + } + let holder = Task { try await service.contactRecords() } + await fulfillment(of: [started], timeout: 2) + let reset = Task { await service.clearState() } + try await Task.sleep(for: .milliseconds(50)) + let identity = String(repeating: "y", count: 52) + let refresh = Task { + try await service.receiveAndObserveState(expectedIdentity: identity) { _ in Date() } + } + try await Task.sleep(for: .milliseconds(50)) + gate.open() + _ = try await holder.value + await reset.value + do { + _ = try await refresh.value + XCTFail("Expected old-runtime refresh rejection") + } catch PubkyServiceError.identityChanged {} + + sdk.inboxReceive = { [] } + sdk.inboxObservation = { + PaykitStateObservation( + publicKey: String(repeating: "b", count: 52), keyGeneration: 1, observedAt: "2027-01-15T08:00:00Z", + contacts: [], linkedPeers: [], paymentRequests: [], receipts: [] + ) + } + let received = expectation(description: "Current receive retained despite mismatched observation") + received.assertForOverFulfill = true + do { + _ = try await service.receiveAndObserveState(expectedIdentity: identity) { _ in + received.fulfill() + return Date() + } + XCTFail("Another identity must not reach the projection") + } catch PubkyServiceError.identityChanged {} + await fulfillment(of: [received], timeout: 2) + } + func testInteractiveWorkOvertakesOnlyQueuedPublicationWithBoundedFairness() async throws { try await assertQueuedOrder( priorities: [.background, .background, .interactive, .interactive, .interactive, .interactive], @@ -994,6 +1213,9 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { var lockedRead: () async -> Void = {} var publicRead: () async -> Void = {} var intake: () async -> Void = {} + var inboxReceive: (() async throws -> [PrivateStreamCounterpartyIntakeReport])? + var inboxObservation: (() async throws -> PaykitStateObservation)? + var backupRevision = "revision" var publication: (PaykitAppCapabilities) async -> Void = { _ in } var paymentMutation: (String) async -> Void = { _ in } var backupExport: () async -> Void = {} @@ -1075,7 +1297,7 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { override func backupStateRevision() async throws -> String { await backupRevisionRead() - return "revision" + return backupRevision } override func observedBackupStateRevision() throws -> ObservedBackupStateRevision? { @@ -1088,6 +1310,9 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { } override func observeState() async throws -> PaykitStateObservation { + if let inboxObservation { + return try await inboxObservation() + } await intake() return PaykitStateObservation( publicKey: "identity", keyGeneration: 0, observedAt: "2026-10-19T00:00:00Z", @@ -1096,6 +1321,9 @@ private final class PublicReadSdk: PaykitSdk, @unchecked Sendable { } override func receivePrivateMessagesFromLinkedPeers() async throws -> [PrivateStreamCounterpartyIntakeReport] { + if let inboxReceive { + return try await inboxReceive() + } await intake() return [] } diff --git a/changelog.d/next/1070.fixed.md b/changelog.d/next/1070.fixed.md new file mode 100644 index 000000000..c0ad1ba6d --- /dev/null +++ b/changelog.d/next/1070.fixed.md @@ -0,0 +1 @@ +Improve how incoming payment requests refresh without interrupting payment review. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 33bebb780..167acc340 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -281,3 +281,16 @@ must explain that its payment details are unsupported and offer no Subscribe con `minimum-onchain-amount.xml` checks requests below the on-chain minimum, including manual retry and a subsequent request at the 547-sat minimum. It uses the same fixture setup and actions on iOS and Android, and stops before sending funds. + +## Automatic refresh during review + +`automatic-presentation.xml` starts after a foreground resume and checks that each received request +appears once. `review-refresh.xml` sends a second request while the first confirmation is open, +then checks that the original review remains usable and the second request arrives after cancellation. +Allow ordinary foreground polling and synchronization to finish; these steps set no arrival deadline +and do not require an immediate refresh when review closes. + +The exact overlap of a saved-contact callback, message receipt, and a queued SDK operation requires +controlled callback timing outside the journey capabilities. Repository tests cover that ordering, +automatic forced-refresh deferral, and later polling. These journeys check the visible request and +payment safeguards without claiming that a specific callback race occurred. diff --git a/journeys/payment-requests/automatic-presentation.xml b/journeys/payment-requests/automatic-presentation.xml index cc7ea79ed..7fb885023 100644 --- a/journeys/payment-requests/automatic-presentation.xml +++ b/journeys/payment-requests/automatic-presentation.xml @@ -4,6 +4,7 @@ On the payer, leave Bitkit unlocked and in the foreground on Home + Background and reopen the payer app, complete app unlock if needed, and leave Home visible while automatic contact and Payment Request refreshes resume On the requester, send the payer contact a Payment Request for 21,000 sats with the note "First request" While the payer prepares the request, verify the existing Payment Request confirmation sheet (id "PaymentRequestConfirm") shows the requester's contact name, 21,000 sats and "First request", with the payment swipe control (id "GRAB") disabled and showing progress Close the confirmation while preparation is still pending @@ -20,6 +21,7 @@ Close Receive Verify Payment Request confirmation (id "PaymentRequestConfirm") appears automatically with 5,000 sats and For shows "Second request" Close the confirmation without paying + Open Payment Requests and verify "First request" and "Second request" each appear once with their original amounts and remain unpaid, then close the list Open Receive and close it again Verify Home remains visible and neither reviewed request reopens automatically On the payer, open the Home menu (id "HeaderMenu") before the next request arrives diff --git a/journeys/payment-requests/review-refresh.xml b/journeys/payment-requests/review-refresh.xml index 2e1a3eeab..9e3c4add1 100644 --- a/journeys/payment-requests/review-refresh.xml +++ b/journeys/payment-requests/review-refresh.xml @@ -7,10 +7,12 @@ On the payer, verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 1,000 sats and Savings is selected Leave the confirmation open for 30 seconds Verify the amount remains 1,000 sats and payment authorization has not started + On the requester, send the payer a second on-chain-only Bitcoin Payment Request for 2,000 sats + Verify the first confirmation still shows 1,000 sats and is not replaced by the second request Swipe to pay using the control with id "GRAB", complete any sending-warning confirmation, and wait on PIN entry for 30 seconds without authorizing Cancel PIN entry, then close the send sheet without paying Verify Activity contains no outgoing payment for this request - On the requester, send the payer a second on-chain-only Bitcoin Payment Request for 2,000 sats + Leave the payer unlocked and in the foreground while ordinary Payment Request synchronization resumes; do not require the next request to arrive immediately when review closes On the payer, verify the second request becomes visible without restarting the app Close the second request without paying, then reopen the first request from the pending-request list Verify the confirmation still shows the original 1,000 sats From 6dd46e465410e1e7fa87c9dd8e1c440eaa34ce4f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 21 Oct 2026 07:47:27 -0600 Subject: [PATCH 2/4] 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. From d9566ad82b51a4cad08e7a9a0408d3983d7f81e9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 11:18:51 +0200 Subject: [PATCH 3/4] fix: keep payment confirmation busy after unlock --- .../Wallets/Send/SendConfirmationView.swift | 9 ++-- .../SendConfirmationLifecycleTests.swift | 51 ++++++++++++++++++- BitkitTests/SendConfirmationSwipeTests.swift | 15 ++++++ changelog.d/next/1039.fixed.md | 2 +- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 799e2305b..564d721c6 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -382,7 +382,8 @@ struct SendConfirmationView: View { hasStartedAutomaticPayment: hasStartedAutomaticPayment, isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed, - isPreparingRequest: preparingRequest != nil + isPreparingRequest: preparingRequest != nil, + isSubmittingPayment: isSubmittingPayment ), isSubmitted: hasStartedAutomaticPayment, swipeProgress: $swipeProgress @@ -806,6 +807,7 @@ struct SendConfirmationView: View { } private func submitPayment(isAutomatic: Bool = false) async throws { + guard !isSubmittingPayment else { return } guard preparingRequest == nil, walletSwitchContext == nil else { throw CancellationError() } if isAutomatic, isFeeRateMissing { guard !Self.automaticPaymentAwaitsFeeRate(isFeeRateMissing: isFeeRateMissing, feeRateLoadFailed: wallet.feeRateLoadFailed) else { @@ -1012,9 +1014,10 @@ struct SendConfirmationView: View { hasStartedAutomaticPayment: Bool, isFeeRateMissing: Bool, feeRateLoadFailed: Bool, - isPreparingRequest: Bool = false + isPreparingRequest: Bool = false, + isSubmittingPayment: Bool = false ) -> Bool { - isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) + isSubmittingPayment || isPreparingRequest || hasStartedAutomaticPayment || (isFeeRateMissing && !feeRateLoadFailed) } private func openManualCoinSelection() async throws { diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift index 4f23ba88f..62dafc610 100644 --- a/BitkitTests/SendConfirmationLifecycleTests.swift +++ b/BitkitTests/SendConfirmationLifecycleTests.swift @@ -460,19 +460,66 @@ final class SendConfirmationLifecycleTests: XCTestCase { XCTAssertEqual(setup.pinChecks, [false], "The payment starts once and is not cancelled while it awaits the PIN") } + func testRecreatedConfirmationDoesNotAuthorizeWhilePaymentIsSubmitting() async throws { + snapshotAppDefaultsDomain() + snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1") + let settings = SettingsViewModel.shared + let pinEnabled = settings.pinEnabled + addTeardownBlock { @MainActor in settings.pinEnabled = pinEnabled } + settings.pinEnabled = true + settings.requirePinForPayments = true + settings.useBiometrics = false + settings.coinSelectionMethod = .autopilot + let app = AppViewModel() + let wallet = WalletViewModel() + let sheets = SheetViewModel() + sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil) + app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkytest", isInitialSubscriptionPayment: true) + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", paymentHash: Data(), amountSatoshis: 6007, + timestampSeconds: 0, expirySeconds: 0, isExpired: false, + description: nil, networkType: .regtest, payeeNodeId: nil + ) + app.selectedWalletToPayFrom = .lightning + wallet.sendAmountSats = 6007 + wallet.totalLightningSats = 1_000_000 + let setup = SendConfirmationTestSetup() + setup.isPending = false + var isSubmittingPayment = true + var path: [SendRoute] = [] + + for _ in 0 ..< 2 { + let window = host( + app: app, + wallet: wallet, + sheets: sheets, + path: Binding(get: { path }, set: { path = $0 }), + setup: setup, + isSubmittingPayment: Binding(get: { isSubmittingPayment }, set: { isSubmittingPayment = $0 }) + ) + try await Task.sleep(for: .milliseconds(500)) + close(window) + } + + XCTAssertTrue(setup.pinChecks.isEmpty, "Restoring an in-flight payment must not request authorization again") + XCTAssertTrue(path.isEmpty, "Ignoring the duplicate entry must preserve the original payment's navigation") + XCTAssertTrue(isSubmittingPayment, "The original payment owns the retained submission flag") + } + private func host( app: AppViewModel, wallet: WalletViewModel, sheets: SheetViewModel, path: Binding<[SendRoute]>, navigation: SendConfirmationTestNavigation? = nil, - setup: SendConfirmationTestSetup? = nil + setup: SendConfirmationTestSetup? = nil, + isSubmittingPayment: Binding = .constant(false) ) -> UIWindow { let hwSend = HwSendCoordinator() let makeConfirmation = { (isSetupPending: Bool) in SendConfirmationView( navigationPath: path, - isSubmittingPayment: .constant(false), + isSubmittingPayment: isSubmittingPayment, hwSend: hwSend, requestPinCheck: { guard let setup else { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index 47985501b..b0e71074f 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -150,6 +150,21 @@ final class SendConfirmationSwipeTests: XCTestCase { )) } + func testManualPaymentKeepsTheSwipeBusyWhenConfirmationIsRecreated() { + XCTAssertTrue(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isSubmittingPayment: true + )) + XCTAssertFalse(SendConfirmationView.isSwipeLoading( + hasStartedAutomaticPayment: false, + isFeeRateMissing: false, + feeRateLoadFailed: false, + isSubmittingPayment: false + )) + } + func testSwipeStopsItsSpinnerOnceTheFeeRateLoadFailed() { XCTAssertFalse(SendConfirmationView.isSwipeLoading( hasStartedAutomaticPayment: false, diff --git a/changelog.d/next/1039.fixed.md b/changelog.d/next/1039.fixed.md index a46649f74..ac1d67e07 100644 --- a/changelog.d/next/1039.fixed.md +++ b/changelog.d/next/1039.fixed.md @@ -1 +1 @@ -Fixed payment requests losing their amount or getting stuck on loading after unlocking the app. +Fixed payment requests losing their amount or getting stuck on loading after unlocking the app, and kept in-flight payments busy after unlock. From 85ef8d543ee8c623f247f013d257c8b2d5720730 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 30 Oct 2026 07:30:17 +0200 Subject: [PATCH 4/4] fix: keep restored progress indicators centered --- Bitkit/Components/ActivityIndicator.swift | 5 +- BitkitTests/SendConfirmationSwipeTests.swift | 69 ++++++++++++++++++++ changelog.d/next/1070.fixed.md | 2 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Bitkit/Components/ActivityIndicator.swift b/Bitkit/Components/ActivityIndicator.swift index c1746503f..2fdece9ba 100644 --- a/Bitkit/Components/ActivityIndicator.swift +++ b/Bitkit/Components/ActivityIndicator.swift @@ -35,8 +35,9 @@ struct ActivityIndicator: View { style: StrokeStyle(lineWidth: 2.5, lineCap: .round) ) .frame(width: size, height: size) - .rotationEffect(.degrees(isRotating ? 360 : 0)) - .animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isRotating) + .animation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { content in + content.rotationEffect(.degrees(isRotating ? 360 : 0)) + } } .opacity(opacity) .onAppear { diff --git a/BitkitTests/SendConfirmationSwipeTests.swift b/BitkitTests/SendConfirmationSwipeTests.swift index b0e71074f..476133820 100644 --- a/BitkitTests/SendConfirmationSwipeTests.swift +++ b/BitkitTests/SendConfirmationSwipeTests.swift @@ -1,8 +1,77 @@ @testable import Bitkit import LDKNode +import SwiftUI import XCTest final class SendConfirmationSwipeTests: XCTestCase { + @MainActor + func testRestoredLoadingSwipeKeepsItsSpinnerInTheKnob() async throws { + let confirmation = NavigationStack(path: .constant([true])) { + Color.black + .navigationDestination(for: Bool.self) { _ in + SwipeButton(title: "Swipe To Pay", accentColor: Color(red: 1, green: 0.4, blue: 0), isLoading: true) { + XCTFail("Restoring a loading control must not start another operation") + } + .frame(width: 343, height: 76) + } + } + let controller = UIHostingController(rootView: AnyView(confirmation)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + window.rootViewController = controller + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil } + + try await assertSpinnerIsInsideKnob(window, name: "initial-loading-confirmation") + controller.rootView = AnyView(Color.black) + try await Task.sleep(for: .milliseconds(300)) + controller.rootView = AnyView(confirmation) + try await assertSpinnerIsInsideKnob(window, name: "restored-loading-confirmation") + } + + @MainActor + private func assertSpinnerIsInsideKnob(_ window: UIWindow, name: String) async throws { + try await Task.sleep(for: .milliseconds(1500)) + let view = try XCTUnwrap(window.rootViewController?.view) + view.layoutIfNeeded() + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let snapshot = UIGraphicsImageRenderer(bounds: view.bounds, format: format).image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) + } + let attachment = XCTAttachment(image: snapshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + let image = try XCTUnwrap(snapshot.cgImage) + let context = try XCTUnwrap(CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let pixels = try XCTUnwrap(context.data).assumingMemoryBound(to: UInt8.self) + var orangePoints: [CGPoint] = [] + for y in 0 ..< image.height { + for x in 0 ..< image.width { + let offset = (y * image.width + x) * 4 + if pixels[offset] > 200, pixels[offset + 1] > 60, pixels[offset + 1] < 160, pixels[offset + 2] < 60 { + orangePoints.append(CGPoint(x: x, y: y)) + } + } + } + let centerX = try Int((XCTUnwrap(orangePoints.map(\.x).min()) + XCTUnwrap(orangePoints.map(\.x).max())) / 2) + let centerY = try Int((XCTUnwrap(orangePoints.map(\.y).min()) + XCTUnwrap(orangePoints.map(\.y).max())) / 2) + var darkPixels = 0 + for y in centerY - 16 ..< centerY + 16 { + for x in centerX - 16 ..< centerX + 16 { + let offset = (y * image.width + x) * 4 + if pixels[offset] < 80, pixels[offset + 1] < 80, pixels[offset + 2] < 80 { + darkPixels += 1 + } + } + } + XCTAssertGreaterThan(darkPixels, 20, "The loading spinner must remain centered in its knob after navigation restoration") + } + func testManualSelectionFailureStopsSubmissionAndAllowsRetry() async throws { enum SelectionError: Error { case unavailable } var attempts = 0 diff --git a/changelog.d/next/1070.fixed.md b/changelog.d/next/1070.fixed.md index c0ad1ba6d..358a57010 100644 --- a/changelog.d/next/1070.fixed.md +++ b/changelog.d/next/1070.fixed.md @@ -1 +1 @@ -Improve how incoming payment requests refresh without interrupting payment review. +Keep payment requests responsive during review and payment progress visible after app unlock.