Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b1d65a7
fix: keep Paykit inbox observation together
ben-kaufman Oct 21, 2026
e16d13b
merge: carry current send routes through the latency stack
ben-kaufman Oct 21, 2026
6dd46e4
fix: avoid inbox waits after preparation errors
ben-kaufman Oct 21, 2026
7cd096f
Merge fix/paykit-proof-refresh-display into fix/paykit-inbox-observation
ben-kaufman Oct 21, 2026
8967dff
Merge fix/paykit-inbox-observation into fix/paykit-preparation-failur…
ben-kaufman Oct 21, 2026
05c9229
Merge fix/paykit-proof-refresh-display into fix/paykit-inbox-observation
ben-kaufman Oct 22, 2026
db83ed7
Merge fix/paykit-inbox-observation into fix/paykit-preparation-failur…
ben-kaufman Oct 22, 2026
a8762e0
merge updated payment latency parent
ben-kaufman Oct 22, 2026
83be268
merge updated payment latency parent
ben-kaufman Oct 22, 2026
02986d7
merge updated payment latency parent
ben-kaufman Oct 22, 2026
430656e
merge updated payment latency parent
ben-kaufman Oct 22, 2026
10f5c7e
merge updated payment latency parent
ben-kaufman Oct 23, 2026
23491ef
merge updated payment latency parent
ben-kaufman Oct 23, 2026
3a5174b
Merge reviewed presentation lifecycle fixes
ben-kaufman Oct 23, 2026
1013543
Merge reviewed presentation lifecycle fixes
ben-kaufman Oct 23, 2026
37e8499
Merge deferred request refresh guard
ben-kaufman Oct 23, 2026
188cc30
Merge deferred request refresh guard
ben-kaufman Oct 23, 2026
59ecb30
Merge active-scene lifecycle regression fixture
ben-kaufman Oct 23, 2026
c933600
Merge active-scene lifecycle regression fixture
ben-kaufman Oct 23, 2026
c26e8b8
Merge formatted lifecycle regression fixture
ben-kaufman Oct 23, 2026
974be75
Merge formatted lifecycle regression fixture
ben-kaufman Oct 23, 2026
6dd86ca
Merge commit '7a3cc5a9502b9db1a07e8c565cf216e42668f95a' into fix/payk…
ben-kaufman Oct 26, 2026
7a3cc5a
Merge commit '5ab68459fe76c5d60a20976ca6d1916606720f37' into fix/payk…
ben-kaufman Oct 26, 2026
5e93d53
Merge updated payment parent into fix/paykit-preparation-failure-refresh
ben-kaufman Oct 26, 2026
3f4e600
Merge updated payment parent into fix/paykit-inbox-observation
ben-kaufman Oct 26, 2026
9d14175
Merge pull request #1081 from synonymdev/fix/paykit-preparation-failu…
ovitrif Oct 26, 2026
ac364f0
Merge validated payment parent into fix/paykit-inbox-observation
ben-kaufman Oct 26, 2026
d9566ad
fix: keep payment confirmation busy after unlock
ben-kaufman Oct 29, 2026
85ef8d5
fix: keep restored progress indicators centered
ben-kaufman Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions Bitkit/Components/ActivityIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 35 additions & 12 deletions Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -1826,6 +1842,7 @@ final class PaykitPaymentRequestManager {
mode: mode,
forceFresh: forceFresh,
messagePriority: refreshPriority ?? (forceFresh ? .ordered : messagePriority),
combineReceiveObservation: !forceFresh,
canRefresh: canRefresh
)
}
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand Down
110 changes: 80 additions & 30 deletions Bitkit/Services/PubkyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<T>(
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<T>(
readRevision: () async throws -> String,
readStateRevision: () throws -> String? = { nil },
Expand Down
9 changes: 6 additions & 3 deletions Bitkit/Views/Wallets/Send/SendConfirmationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading