diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 80aa5d6a9..a811ccb2b 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -691,7 +691,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/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/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 9765e4e8c..3a2d51ebb 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -614,6 +614,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 @@ -703,17 +707,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 } @@ -1826,6 +1842,7 @@ final class PaykitPaymentRequestManager { mode: mode, forceFresh: forceFresh, messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority), + combineReceiveObservation: !forceFresh, canRefresh: canRefresh ) } @@ -1854,6 +1871,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 } @@ -1864,7 +1882,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 @@ -1884,7 +1902,8 @@ final class PaykitPaymentRequestManager { generation: generation, excludingProtectedRequestId: excludingProtectedRequestId, mode: mode, - messagePriority: messagePriority + messagePriority: messagePriority, + combineReceiveObservation: combineReceiveObservation ) guard generation == refreshGeneration else { return false } return refreshed @@ -1963,7 +1982,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 @@ -2531,12 +2551,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) @@ -2745,6 +2767,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 { @@ -2795,7 +2818,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/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index e8c51278b..5168fdc2c 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1080,6 +1080,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) @@ -1685,39 +1727,47 @@ 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() - } - try await admit() - }, - 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 admit() + }) { 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/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/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 88ed354d0..9a574ba84 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -589,10 +589,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 { @@ -4580,25 +4621,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 { @@ -4731,6 +4776,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]) @@ -6658,6 +6741,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) @@ -6668,6 +6753,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 { @@ -6676,6 +6812,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) @@ -6686,6 +6823,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 { @@ -7912,16 +8052,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) @@ -10201,6 +10350,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] = [] @@ -10280,6 +10430,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, @@ -10512,6 +10673,9 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling, PaykitPaym ) } if let acceptanceResponseError { + if let acceptanceCommittedRecord { + records.append(acceptanceCommittedRecord) + } throw acceptanceResponseError } if acceptFailuresAfterRemoval > 0 { @@ -10568,8 +10732,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/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/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..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 @@ -150,6 +219,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. diff --git a/changelog.d/next/1070.fixed.md b/changelog.d/next/1070.fixed.md new file mode 100644 index 000000000..358a57010 --- /dev/null +++ b/changelog.d/next/1070.fixed.md @@ -0,0 +1 @@ +Keep payment requests responsive during review and payment progress visible after app unlock. 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. 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 194ca9e1b..2d4a0f6d5 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