From 3e537da8b556aeea113a67a946e62171d8e681ce Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Aug 2026 09:05:51 -0500 Subject: [PATCH 1/6] fix: harden Paykit contact payments --- .../PrivatePaykitService+Contacts.swift | 1 - Bitkit/ViewModels/AppViewModel.swift | 20 +++++++++++++------ changelog.d/next/paykit-request-qa.fixed.md | 1 + 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 changelog.d/next/paykit-request-qa.fixed.md diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 0e0c2d151..fc1dd8fc6 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -352,7 +352,6 @@ extension PrivatePaykitService { let linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths let publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths if let error = receiverPathSelection.error { - firstError = firstError ?? error Logger.warn( "Failed to inspect private Paykit receiver markers for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", context: "PrivatePaykit" diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 6e53d2a8f..58c6c2027 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -492,6 +492,7 @@ extension AppViewModel { data = try await decode(invoice: uri) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) } + let requestedAmount = contactPaymentContext?.incomingPaymentRequest?.amountSats if scope == .onchainPayments { guard ShopPaymentRequest.isOnchainPayment(data) else { throw ScanHandlingError.unsupportedRequest } @@ -528,7 +529,7 @@ extension AppViewModel { if nodeIsRunning { // Node is running → we have fresh balances; validate immediately. // Prefer lightning; if insufficient or no channels/capacity, fall back to onchain. - let canSendLightning = lightningService.canSend(amountSats: lightningInvoice.amountSatoshis) + let canSendLightning = lightningService.canSend(amountSats: requestedAmount ?? lightningInvoice.amountSatoshis) if canSendLightning { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) @@ -551,7 +552,10 @@ extension AppViewModel { lightningService.balances?.spendableOnchainBalanceSats ?? 0, alternativeOnchainBalanceSats ) - guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { + guard validateOnchainBalance( + invoiceAmount: requestedAmount ?? invoice.amountSatoshis, + onchainBalance: onchainBalance + ) else { return } @@ -578,7 +582,10 @@ extension AppViewModel { lightningService.balances?.spendableOnchainBalanceSats ?? 0, alternativeOnchainBalanceSats ) - guard validateOnchainBalance(invoiceAmount: invoice.amountSatoshis, onchainBalance: onchainBalance) else { + guard validateOnchainBalance( + invoiceAmount: requestedAmount ?? invoice.amountSatoshis, + onchainBalance: onchainBalance + ) else { return } } @@ -609,20 +616,21 @@ extension AppViewModel { // If node is running, we can check for channels and validate immediately if lightningService.status?.isRunning == true { + let paymentAmount = requestedAmount ?? invoice.amountSatoshis // If user has no channels at all, they can never pay a pure lightning invoice. // Show insufficient spending toast and do not navigate to the send flow. let hasAnyChannels = (lightningService.channels?.isEmpty == false) if !hasAnyChannels { let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - showInsufficientSpendingToast(invoiceAmount: invoice.amountSatoshis, spendingBalance: spendingBalance) + showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) return } // If channels are usable, validate capacity immediately if let channels = lightningService.channels, channels.contains(where: \.isUsable) { - guard lightningService.canSend(amountSats: invoice.amountSatoshis) else { + guard lightningService.canSend(amountSats: paymentAmount) else { let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - showInsufficientSpendingToast(invoiceAmount: invoice.amountSatoshis, spendingBalance: spendingBalance) + showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) return } } diff --git a/changelog.d/next/paykit-request-qa.fixed.md b/changelog.d/next/paykit-request-qa.fixed.md new file mode 100644 index 000000000..568aeec57 --- /dev/null +++ b/changelog.d/next/paykit-request-qa.fixed.md @@ -0,0 +1 @@ +Contact payments now ignore malformed receiver markers from other contacts and show the correct insufficient-balance error for unaffordable payment requests. From 0378ec68fa8995e22df3277941c99d73a5f125e3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 26 Aug 2026 09:07:23 -0500 Subject: [PATCH 2/6] chore: rename changelog fragment --- changelog.d/next/{paykit-request-qa.fixed.md => 684.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-request-qa.fixed.md => 684.fixed.md} (100%) diff --git a/changelog.d/next/paykit-request-qa.fixed.md b/changelog.d/next/684.fixed.md similarity index 100% rename from changelog.d/next/paykit-request-qa.fixed.md rename to changelog.d/next/684.fixed.md From fc7767c9639c78bcc1a7036884dea9909c730b33 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 12:42:04 -0500 Subject: [PATCH 3/6] fix: stop failed request retries --- Bitkit/AppScene.swift | 7 ++++++- Bitkit/Services/ContactPaymentsService.swift | 3 ++- Bitkit/ViewModels/AppViewModel.swift | 5 +++++ BitkitTests/ContactPaymentsServiceTests.swift | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 35b222c27..dc121ee7f 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -866,9 +866,14 @@ struct AppScene: View { return } guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { + let shouldStopAutomaticPresentation = app.didRejectScannedPaymentForInsufficientBalance app.resetSendState() wallet.resetSendState(speed: settings.defaultTransactionSpeed) - paykitPaymentRequestManager.deferPresentation(request) + if shouldStopAutomaticPresentation { + _ = paykitPaymentRequestManager.markPresentedIfPending(request) + } else { + paykitPaymentRequestManager.deferPresentation(request) + } continue } diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 85e924625..989a6576e 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -116,6 +116,8 @@ enum ContactPaymentsService { operations: Operations, defaults: UserDefaults ) async throws { + defaults.set(canUsePrivatePayments, forKey: PrivatePaykitService.publishingEnabledKey) + if canUsePrivatePayments, let error = await operations.preparePrivateEndpoints( contactPublicKeys, @@ -128,7 +130,6 @@ enum ContactPaymentsService { try await operations.syncPublicEndpoints(true) defaults.set(true, forKey: PublicPaykitService.publishingEnabledKey) - defaults.set(canUsePrivatePayments, forKey: PrivatePaykitService.publishingEnabledKey) defaults.set(true, forKey: confirmedPreferenceKey) operations.setPublicCleanupPending(false) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 58c6c2027..05ef49657 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -62,6 +62,7 @@ class AppViewModel: ObservableObject { @Published var isManualEntryInputValid: Bool = false @Published var manualEntryValidationResult: ManualEntryValidationResult = .empty @Published var contactPaymentContext: ContactPaymentContext? + private(set) var didRejectScannedPaymentForInsufficientBalance = false // LNURL @Published var lnurlPayData: LnurlPayData? @@ -161,6 +162,7 @@ class AppViewModel: ObservableObject { /// Shows insufficient spending balance toast with amount-specific or generic description private func showInsufficientSpendingToast(invoiceAmount: UInt64, spendingBalance: UInt64) { + didRejectScannedPaymentForInsufficientBalance = true let amountNeeded = invoiceAmount > spendingBalance ? invoiceAmount - spendingBalance : 0 let description = amountNeeded > 0 ? t( @@ -181,6 +183,7 @@ class AppViewModel: ObservableObject { private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool { if invoiceAmount > 0 { guard onchainBalance >= invoiceAmount else { + didRejectScannedPaymentForInsufficientBalance = true let amountNeeded = invoiceAmount - onchainBalance toast( type: .error, @@ -196,6 +199,7 @@ class AppViewModel: ObservableObject { } else { // Zero-amount invoice: user must have some balance to proceed guard onchainBalance > 0 else { + didRejectScannedPaymentForInsufficientBalance = true toast( type: .error, title: t("other__pay_insufficient_savings"), @@ -430,6 +434,7 @@ extension AppViewModel { } } scannedDataHandlingId = handlingId + didRejectScannedPaymentForInsufficientBalance = false defer { if scannedDataHandlingId == handlingId { scannedDataHandlingId = nil diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 8034c21c9..a29ba712c 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -39,6 +39,9 @@ final class ContactPaymentsServiceTests: XCTestCase { func testEnablingContactPaymentsPublishesPublicAndPrivateEndpoints() async throws { try await withIsolatedDefaultsAsync { defaults in let operations = OperationsSpy() + operations.onPreparePrivateEndpoints = { + XCTAssertTrue(defaults.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + } try await ContactPaymentsService.setEnabled( true, @@ -213,6 +216,7 @@ final class ContactPaymentsServiceTests: XCTestCase { var publicPublicationFailures: Set = [] var privatePublicationFailures: Set = [] var privateRemovalFailures: Set = [] + var onPreparePrivateEndpoints: (() -> Void)? func makeOperations() -> ContactPaymentsService.Operations { ContactPaymentsService.Operations( @@ -224,6 +228,7 @@ final class ContactPaymentsServiceTests: XCTestCase { } }, preparePrivateEndpoints: { contactPublicKeys, requiresImmediatePublication in + self.onPreparePrivateEndpoints?() self.calls.append("private:publish") self.privatePublications.append( PrivatePublication( From 154b3d62d523696942834bbf4386d3e7b9c94c06 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 17:40:45 -0500 Subject: [PATCH 4/6] test: cover Paykit request QA fixes --- Bitkit/AppScene.swift | 16 +-- .../PaykitPaymentRequestService.swift | 23 ++++ .../PrivatePaykitService+Contacts.swift | 103 ++++++++++++++---- Bitkit/ViewModels/AppViewModel.swift | 63 ++++++++--- .../PaykitPaymentRequestServiceTests.swift | 52 +++++++++ BitkitTests/PrivatePaykitServiceTests.swift | 68 ++++++++++++ 6 files changed, 277 insertions(+), 48 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index dc121ee7f..7cbeef3f1 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -866,14 +866,14 @@ struct AppScene: View { return } guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { - let shouldStopAutomaticPresentation = app.didRejectScannedPaymentForInsufficientBalance - app.resetSendState() - wallet.resetSendState(speed: settings.defaultTransactionSpeed) - if shouldStopAutomaticPresentation { - _ = paykitPaymentRequestManager.markPresentedIfPending(request) - } else { - paykitPaymentRequestManager.deferPresentation(request) - } + PaykitPaymentRequestPresentationCoordinator.handleUnavailablePaymentRoute( + request, + app: app, + manager: paykitPaymentRequestManager, + resetWalletSendState: { + wallet.resetSendState(speed: settings.defaultTransactionSpeed) + } + ) continue } diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c492a16b0..c63ed5621 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -542,6 +542,21 @@ protocol PaykitPaymentRequestPresentationStoring { func save(_ ids: Set, identity: String) throws } +enum PaykitPaymentRequestPresentationCoordinator { + @MainActor + static func handleUnavailablePaymentRoute( + _ request: PaykitPaymentRequest, + app: AppViewModel, + manager: PaykitPaymentRequestManager, + resetWalletSendState: () -> Void + ) { + let insufficientBalance = app.didRejectScannedPaymentForInsufficientBalance + app.resetSendState() + resetWalletSendState() + manager.handleUnavailablePaymentRoute(request, insufficientBalance: insufficientBalance) + } +} + struct PaykitPaymentRequestPresentationStore: PaykitPaymentRequestPresentationStoring { private struct State: Codable { var idsByIdentity: [String: [PaykitPaymentRequest.ID]] @@ -898,6 +913,14 @@ final class PaykitPaymentRequestManager { return true } + func handleUnavailablePaymentRoute(_ request: PaykitPaymentRequest, insufficientBalance: Bool) { + if insufficientBalance { + _ = markPresentedIfPending(request) + } else { + deferPresentation(request) + } + } + private func performRefresh( generation: Int, excludingProtectedRequestId: PaykitPaymentRequest.ID? diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index fc1dd8fc6..da3e1a53f 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -4,6 +4,16 @@ import Paykit // MARK: - Saved Contacts extension PrivatePaykitService { + struct EndpointPublicationOperations { + let currentPublicKey: () async -> String? + let linkedReceiverPaths: (_ reason: String) async -> (paths: [String: Set], error: Error?) + let receiverPaths: (_ publicKey: String) async throws -> [String] + let receiverPathSelection: (_ publicKey: String, _ receiverPaths: [String]) async throws -> PrivateReceiverPathSelection + let ensureLink: (_ publicKey: String, _ receiverPath: String) async throws -> Void + let buildEndpoints: (_ publicKey: String, _ receiverPath: String) async throws -> [PublicPaykitService.Endpoint] + let syncPaymentLists: (_ updates: [PrivatePaymentListReservationUpdateInput]) async throws -> PrivatePaymentListDeliveryReport + } + @discardableResult func prepareSavedContacts( _ publicKeys: [String], @@ -293,15 +303,32 @@ extension PrivatePaykitService { reason: String, forceRefreshLightning: Bool = false, requireImmediatePublication: Bool + ) async -> Error? { + let operations = endpointPublicationOperations( + wallet: wallet, + forceRefreshLightning: forceRefreshLightning + ) + return await syncLocalEndpointPublication( + for: publicKeys, + reason: reason, + requireImmediatePublication: requireImmediatePublication, + operations: operations + ) + } + + func syncLocalEndpointPublication( + for publicKeys: [String], + reason: String, + requireImmediatePublication: Bool, + operations: EndpointPublicationOperations ) async -> Error? { do { return try await withPublicationLock { await syncLocalEndpointPublicationLocked( for: publicKeys, - wallet: wallet, reason: reason, - forceRefreshLightning: forceRefreshLightning, - requireImmediatePublication: requireImmediatePublication + requireImmediatePublication: requireImmediatePublication, + operations: operations ) } } catch { @@ -309,21 +336,60 @@ extension PrivatePaykitService { } } + private func endpointPublicationOperations( + wallet: WalletViewModel, + forceRefreshLightning: Bool + ) -> EndpointPublicationOperations { + EndpointPublicationOperations( + currentPublicKey: { + await PubkyService.currentPublicKey() + }, + linkedReceiverPaths: { reason in + await self.linkedReceiverPathsSnapshot(reason: reason) + }, + receiverPaths: { publicKey in + try await self.receiverPathsForSavedContact(publicKey: publicKey) + }, + receiverPathSelection: { publicKey, receiverPaths in + try await PaykitSdkService.shared.privateReceiverPathSelection( + publicKey: publicKey, + savedReceiverPaths: receiverPaths + ) + }, + ensureLink: { publicKey, receiverPath in + _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey, receiverPath: receiverPath) + }, + buildEndpoints: { publicKey, receiverPath in + try await self.buildLocalEndpoints( + for: publicKey, + receiverPath: receiverPath, + wallet: wallet, + forceRefreshLightning: forceRefreshLightning + ) + }, + syncPaymentLists: { updates in + try await PaykitSdkService.shared.syncPrivatePaymentListsWithReservations( + updates, + clearUnlistedLinkedPeers: false + ) + } + ) + } + private func syncLocalEndpointPublicationLocked( for publicKeys: [String], - wallet: WalletViewModel, reason: String, - forceRefreshLightning: Bool = false, - requireImmediatePublication: Bool + requireImmediatePublication: Bool, + operations: EndpointPublicationOperations ) async -> Error? { let publicKeys = normalizedSavedContactKeys(publicKeys) guard !publicKeys.isEmpty else { return nil } - guard await PubkyService.currentPublicKey() != nil else { + guard await operations.currentPublicKey() != nil else { return requireImmediatePublication ? PubkyServiceError.sessionNotActive : nil } - let linkedReceiverPathsSnapshot = await linkedReceiverPathsSnapshot(reason: reason) + let linkedReceiverPathsSnapshot = await operations.linkedReceiverPaths(reason) var firstError = linkedReceiverPathsSnapshot.error var updates = [PrivatePaymentListReservationUpdateInput]() var linkRetryKeys = [PrivateMessageDrainRetryKey]() @@ -331,7 +397,7 @@ extension PrivatePaykitService { for publicKey in publicKeys { let receiverPaths: [String] do { - receiverPaths = try await receiverPathsForSavedContact(publicKey: publicKey) + receiverPaths = try await operations.receiverPaths(publicKey) } catch { firstError = firstError ?? error Logger.warn( @@ -342,10 +408,7 @@ extension PrivatePaykitService { } let receiverPathSelection: PrivateReceiverPathSelection do { - receiverPathSelection = try await PaykitSdkService.shared.privateReceiverPathSelection( - publicKey: publicKey, - savedReceiverPaths: receiverPaths - ) + receiverPathSelection = try await operations.receiverPathSelection(publicKey, receiverPaths) } catch { return error } @@ -366,7 +429,7 @@ extension PrivatePaykitService { for receiverPath in Set(linkableReceiverPaths).union(cleanupReceiverPaths) { linkRetryKeys.append(PrivateMessageDrainRetryKey(publicKey: publicKey, receiverPath: receiverPath)) do { - _ = try await PaykitSdkService.shared.ensureLinkWithPeer(publicKey, receiverPath: receiverPath) + try await operations.ensureLink(publicKey, receiverPath) } catch { Logger.warn( "Failed to prepare private Paykit link for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", @@ -385,12 +448,7 @@ extension PrivatePaykitService { for receiverPath in publicationReceiverPaths { do { - let endpoints = try await buildLocalEndpoints( - for: publicKey, - receiverPath: receiverPath, - wallet: wallet, - forceRefreshLightning: forceRefreshLightning - ) + let endpoints = try await operations.buildEndpoints(publicKey, receiverPath) let reservations = reservations(from: endpoints, publicKey: publicKey, receiverPath: receiverPath) let update = PrivatePaymentListReservationUpdateInput( counterparty: publicKey, @@ -414,10 +472,7 @@ extension PrivatePaykitService { } do { - let report = try await PaykitSdkService.shared.syncPrivatePaymentListsWithReservations( - updates, - clearUnlistedLinkedPeers: false - ) + let report = try await operations.syncPaymentLists(updates) let deliveryError = applyPrivatePaymentListDeliveryReport(report, reason: reason) firstError = firstError ?? deliveryError let retryKeys = linkRetryKeys + privatePaymentListDeliveryRetryKeys(from: report) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 05ef49657..7c8fecf75 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -52,6 +52,36 @@ enum ManualEntryValidationResult: Equatable { case expiredLightningOnly } +struct ScanPaymentState { + let isNodeRunning: Bool + let spendableOnchainBalanceSats: UInt64 + let totalLightningBalanceSats: UInt64 + let hasChannels: Bool + let hasUsableChannels: Bool +} + +struct ScanPaymentOperations { + let state: () -> ScanPaymentState + let canSendLightning: (_ amountSats: UInt64) -> Bool + + @MainActor + static func live(lightningService: LightningService) -> ScanPaymentOperations { + ScanPaymentOperations( + state: { + let channels = lightningService.channels + return ScanPaymentState( + isNodeRunning: lightningService.status?.isRunning == true, + spendableOnchainBalanceSats: lightningService.balances?.spendableOnchainBalanceSats ?? 0, + totalLightningBalanceSats: lightningService.balances?.totalLightningBalanceSats ?? 0, + hasChannels: channels?.isEmpty == false, + hasUsableChannels: channels?.contains(where: \.isUsable) == true + ) + }, + canSendLightning: lightningService.canSend + ) + } +} + @MainActor class AppViewModel: ObservableObject { // Send flow @@ -120,6 +150,7 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel + private let scanPaymentOperations: ScanPaymentOperations private var scannedDataHandlingId: UUID? private var manualEntryValidationSequence: UInt64 = 0 @@ -131,12 +162,14 @@ class AppViewModel: ObservableObject { lightningService: LightningService = .shared, coreService: CoreService = .shared, sheetViewModel: SheetViewModel, - navigationViewModel: NavigationViewModel + navigationViewModel: NavigationViewModel, + scanPaymentOperations: ScanPaymentOperations? = nil ) { self.lightningService = lightningService self.coreService = coreService self.sheetViewModel = sheetViewModel self.navigationViewModel = navigationViewModel + self.scanPaymentOperations = scanPaymentOperations ?? .live(lightningService: lightningService) setupManualEntryValidationDebounce() @@ -498,6 +531,7 @@ extension AppViewModel { try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) } let requestedAmount = contactPaymentContext?.incomingPaymentRequest?.amountSats + let paymentState = scanPaymentOperations.state() if scope == .onchainPayments { guard ShopPaymentRequest.isOnchainPayment(data) else { throw ScanHandlingError.unsupportedRequest } @@ -529,12 +563,12 @@ extension AppViewModel { let lnNetworkMatch = !NetworkValidationHelper.isNetworkMismatch(addressNetwork: lnNetwork, currentNetwork: Env.network) if lnNetworkMatch, !lightningInvoice.isExpired { - let nodeIsRunning = lightningService.status?.isRunning == true + let nodeIsRunning = paymentState.isNodeRunning if nodeIsRunning { // Node is running → we have fresh balances; validate immediately. // Prefer lightning; if insufficient or no channels/capacity, fall back to onchain. - let canSendLightning = lightningService.canSend(amountSats: requestedAmount ?? lightningInvoice.amountSatoshis) + let canSendLightning = scanPaymentOperations.canSendLightning(requestedAmount ?? lightningInvoice.amountSatoshis) if canSendLightning { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) @@ -545,7 +579,7 @@ extension AppViewModel { // lightning. The send sheet shows the sync overlay and either proceeds // over lightning when the peer reconnects or falls back to onchain // after its timeout. - if let channels = lightningService.channels, !channels.isEmpty, !channels.contains(where: \.isUsable) { + if paymentState.hasChannels, !paymentState.hasUsableChannels { handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice) return } @@ -554,7 +588,7 @@ extension AppViewModel { // usable channels without capacity). // Fall back to onchain and validate onchain balance immediately. let onchainBalance = max( - lightningService.balances?.spendableOnchainBalanceSats ?? 0, + paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats ) guard validateOnchainBalance( @@ -582,9 +616,9 @@ extension AppViewModel { guard !invoice.address.isEmpty else { return } // If node is running, validate balance immediately - if lightningService.status?.isRunning == true { + if paymentState.isNodeRunning { let onchainBalance = max( - lightningService.balances?.spendableOnchainBalanceSats ?? 0, + paymentState.spendableOnchainBalanceSats, alternativeOnchainBalanceSats ) guard validateOnchainBalance( @@ -620,22 +654,19 @@ extension AppViewModel { } // If node is running, we can check for channels and validate immediately - if lightningService.status?.isRunning == true { + if paymentState.isNodeRunning { let paymentAmount = requestedAmount ?? invoice.amountSatoshis // If user has no channels at all, they can never pay a pure lightning invoice. // Show insufficient spending toast and do not navigate to the send flow. - let hasAnyChannels = (lightningService.channels?.isEmpty == false) - if !hasAnyChannels { - let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) + if !paymentState.hasChannels { + showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return } // If channels are usable, validate capacity immediately - if let channels = lightningService.channels, channels.contains(where: \.isUsable) { - guard lightningService.canSend(amountSats: paymentAmount) else { - let spendingBalance = lightningService.balances?.totalLightningBalanceSats ?? 0 - showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: spendingBalance) + if paymentState.hasUsableChannels { + guard scanPaymentOperations.canSendLightning(paymentAmount) else { + showInsufficientSpendingToast(invoiceAmount: paymentAmount, spendingBalance: paymentState.totalLightningBalanceSats) return } } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 42d528107..bf3c82b1a 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -218,6 +218,58 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.requestsForPresentation().isEmpty) } + func testUnaffordableRequestUsesRequestedAmountAndStopsAutomaticPresentation() async throws { + let clock = PaymentRequestTestClock(Date()) + let onchainMethod = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh) + let sdk = try PaymentRequestSdkMock(records: [ + paymentRequestRecord(amount: "0.00001", endpoints: [onchainMethod.rawValue]), + ]) + let manager = paymentRequestManager(sdk: sdk, clock: clock) + await manager.refresh() + let request = try XCTUnwrap(manager.requestsForPresentation().first) + let app = AppViewModel( + sheetViewModel: SheetViewModel(), + navigationViewModel: NavigationViewModel(), + scanPaymentOperations: ScanPaymentOperations( + state: { + ScanPaymentState( + isNodeRunning: true, + spendableOnchainBalanceSats: 100, + totalLightningBalanceSats: 0, + hasChannels: false, + hasUsableChannels: false + ) + }, + canSendLightning: { _ in false } + ) + ) + let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request) + XCTAssertTrue(app.claimContactPaymentContext(context)) + + try await app.handleScannedData( + "bitcoin:bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x", + claimedContactPaymentContext: context + ) + + XCTAssertNil(app.scannedOnchainInvoice) + XCTAssertNil(app.scannedLightningInvoice) + XCTAssertTrue(app.didRejectScannedPaymentForInsufficientBalance) + + var didResetWalletSendState = false + PaykitPaymentRequestPresentationCoordinator.handleUnavailablePaymentRoute( + request, + app: app, + manager: manager, + resetWalletSendState: { didResetWalletSendState = true } + ) + clock.advance(by: 2) + + XCTAssertTrue(didResetWalletSendState) + XCTAssertNil(app.contactPaymentContext) + XCTAssertEqual(manager.pendingRequests, [request]) + XCTAssertTrue(manager.requestsForPresentation().isEmpty) + } + func testDeferredRequestUsesIncreasingPresentationBackoff() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let clock = PaymentRequestTestClock(now) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index fb8407358..93448be6b 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import Paykit import XCTest final class PrivatePaykitServiceTests: XCTestCase { @@ -349,6 +350,73 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertNotNil(failedContactState) XCTAssertEqual(PrivatePaykitService.pendingDeletedContactCleanupKeys(), [failedPublicKey]) } + + func testImmediatePublicationContinuesAfterMarkerInspectionFailure() async throws { + let failedPublicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let successfulPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let markerError = NSError(domain: "PrivatePaykitServiceTests", code: 1) + let endpoint = PublicPaykitService.Endpoint( + methodId: .regtestOnchainP2wpkh, + value: "bcrt1qendpoint", + min: nil, + max: nil, + rawPayload: #"{"value":"bcrt1qendpoint"}"# + ) + var syncedUpdates = [PrivatePaymentListReservationUpdateInput]() + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + linkedReceiverPaths: { _ in ([:], nil) }, + receiverPaths: { _ in [PaykitReceiverPath.wallet] }, + receiverPathSelection: { publicKey, _ in + if publicKey == failedPublicKey { + return PrivateReceiverPathSelection( + linkableReceiverPaths: [], + publishableReceiverPaths: [], + cleanupProtectedReceiverPaths: [PaykitReceiverPath.wallet], + error: markerError + ) + } + return PrivateReceiverPathSelection( + linkableReceiverPaths: [], + publishableReceiverPaths: [PaykitReceiverPath.wallet], + cleanupProtectedReceiverPaths: [], + error: nil + ) + }, + ensureLink: { _, _ in + XCTFail("No link should be prepared for this fixture") + }, + buildEndpoints: { publicKey, receiverPath in + XCTAssertEqual(publicKey, successfulPublicKey) + XCTAssertEqual(receiverPath, PaykitReceiverPath.wallet) + return [endpoint] + }, + syncPaymentLists: { updates in + syncedUpdates = updates + return PrivatePaymentListDeliveryReport( + queued: [], + cleared: [], + failedToQueue: [], + failedToDeliver: [] + ) + } + ) + let service = PrivatePaykitService() + + let error = await service.syncLocalEndpointPublication( + for: [failedPublicKey, successfulPublicKey], + reason: "test", + requireImmediatePublication: true, + operations: operations + ) + + XCTAssertNil(error) + XCTAssertEqual(syncedUpdates.count, 1) + let update = try XCTUnwrap(syncedUpdates.first) + XCTAssertEqual(update.counterparty, successfulPublicKey) + XCTAssertEqual(update.counterpartyReceiverPath, PaykitReceiverPath.wallet) + XCTAssertFalse(update.reservations.isEmpty) + } } private extension PrivatePaykitService { From 2d90e0ef03a0bde366193b0b290ee19fcaf66dff Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 08:13:55 -0500 Subject: [PATCH 5/6] fix: defer unavailable private publication --- Bitkit/Services/ContactPaymentsService.swift | 2 +- .../PrivatePaykitService+Contacts.swift | 3 ++- .../PrivatePaykitService+Invoices.swift | 23 +++++++++++-------- BitkitTests/ContactPaymentsServiceTests.swift | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 989a6576e..2d26c5f2b 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -121,7 +121,7 @@ enum ContactPaymentsService { if canUsePrivatePayments, let error = await operations.preparePrivateEndpoints( contactPublicKeys, - true + false ) { throw error diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index da3e1a53f..ad57e75c9 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -21,7 +21,8 @@ extension PrivatePaykitService { requireImmediatePublication: Bool = false ) async -> Error? { let publicKeys = rememberSavedContacts(publicKeys, replacing: true) - guard await canPublishPrivateEndpoints(wallet: wallet) else { + if let reason = await privateEndpointPublicationUnavailabilityReason(wallet: wallet) { + Logger.info("Deferring private Paykit endpoint publication during prepare: \(reason)", context: "PrivatePaykitService") await prepareRelevantPrivateLinksIfAvailable(publicKeys, reason: "prepare") return requireImmediatePublication && !publicKeys.isEmpty ? PrivatePaykitError.privateUnavailable : nil } diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 62518530a..b917be173 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -84,17 +84,20 @@ extension PrivatePaykitService { @MainActor func canPublishPrivateEndpoints(wallet: WalletViewModel) async -> Bool { - guard PaykitFeatureFlags.isUIEnabled, - UserDefaults.standard.bool(forKey: Self.publishingEnabledKey), - UIApplication.shared.applicationState == .active, - wallet.walletExists == true, - wallet.nodeLifecycleState == .running, - let ownPublicKey = await PubkyService.currentPublicKey() - else { - return false - } + await privateEndpointPublicationUnavailabilityReason(wallet: wallet) == nil + } - return PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) + @MainActor + func privateEndpointPublicationUnavailabilityReason(wallet: WalletViewModel) async -> String? { + guard PaykitFeatureFlags.isUIEnabled else { return "Paykit UI is disabled" } + guard UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) else { return "private publication is disabled" } + guard UIApplication.shared.applicationState == .active else { return "the app is not active" } + guard wallet.walletExists == true else { return "the wallet is unavailable" } + guard wallet.nodeLifecycleState == .running else { return "the Lightning node is not running" } + guard let ownPublicKey = await PubkyService.currentPublicKey() else { return "the Pubky session is not active" } + guard PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) else { return "the local Pubky secret key is unavailable" } + + return nil } @MainActor diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index a29ba712c..e9af32bb4 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -54,7 +54,7 @@ final class ContactPaymentsServiceTests: XCTestCase { XCTAssertEqual(operations.publicPublicationValues, [true]) XCTAssertEqual(operations.privatePublications.count, 1) XCTAssertEqual(operations.privatePublications[0].contactPublicKeys, ["contact-a", "contact-b"]) - XCTAssertTrue(operations.privatePublications[0].requiresImmediatePublication) + XCTAssertFalse(operations.privatePublications[0].requiresImmediatePublication) XCTAssertEqual(operations.calls, ["private:publish", "public:true"]) XCTAssertEqual(operations.privateRemovalCount, 0) XCTAssertEqual(operations.publicCleanupValues, [false]) From ab3308b84d64d548c11df4d4ddd5150b593449f3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 11:36:54 -0500 Subject: [PATCH 6/6] fix: defer Paykit marker failures --- .../PrivatePaykitService+Contacts.swift | 7 +++- BitkitTests/PrivatePaykitServiceTests.swift | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index ad57e75c9..cec41d792 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -411,7 +411,12 @@ extension PrivatePaykitService { do { receiverPathSelection = try await operations.receiverPathSelection(publicKey, receiverPaths) } catch { - return error + firstError = firstError ?? error + Logger.warn( + "Failed to select private Paykit receiver paths for \(PubkyPublicKeyFormat.redacted(publicKey)) during \(reason): \(error)", + context: "PrivatePaykit" + ) + continue } let linkableReceiverPaths = receiverPathSelection.linkableReceiverPaths let publicationReceiverPaths = receiverPathSelection.publishableReceiverPaths diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 93448be6b..47a9b4019 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -417,6 +417,41 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertEqual(update.counterpartyReceiverPath, PaykitReceiverPath.wallet) XCTAssertFalse(update.reservations.isEmpty) } + + func testDeferredPublicationIgnoresReceiverPathSelectionFailure() async { + let publicKey = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let markerError = NSError(domain: "PrivatePaykitServiceTests", code: 1) + let operations = PrivatePaykitService.EndpointPublicationOperations( + currentPublicKey: { "pubkylocal" }, + linkedReceiverPaths: { _ in ([:], nil) }, + receiverPaths: { _ in [PaykitReceiverPath.wallet] }, + receiverPathSelection: { _, _ in throw markerError }, + ensureLink: { _, _ in XCTFail("No link should be prepared") }, + buildEndpoints: { _, _ in + XCTFail("No endpoint should be built") + return [] + }, + syncPaymentLists: { _ in + XCTFail("No payment list should be synced") + return PrivatePaymentListDeliveryReport( + queued: [], + cleared: [], + failedToQueue: [], + failedToDeliver: [] + ) + } + ) + let service = PrivatePaykitService() + + let error = await service.syncLocalEndpointPublication( + for: [publicKey], + reason: "test", + requireImmediatePublication: false, + operations: operations + ) + + XCTAssertNil(error) + } } private extension PrivatePaykitService {