From 302687bd9737327f339bd8ed75c599e44bddab7d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 18 May 2026 12:24:05 -0500 Subject: [PATCH 1/4] feat: add contact payment ui --- .gitignore | 1 + Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../icons/user-minus.imageset/Contents.json | 15 ++ .../icons/user-minus.imageset/user-minus.svg | 6 + Bitkit/Components/PubkyContactAvatar.swift | 30 +++ Bitkit/Components/PubkyContactRow.swift | 42 +++ Bitkit/MainNavView.swift | 2 + Bitkit/Managers/PubkyProfileManager.swift | 3 +- .../Localization/en.lproj/Localizable.strings | 12 +- .../PrivatePaykitService+Contacts.swift | 83 +++++- .../PrivatePaykitService+Endpoints.swift | 108 ++++++-- .../PrivatePaykitService+Errors.swift | 5 +- .../PrivatePaykitService+Invoices.swift | 16 +- .../PrivatePaykitService+Payments.swift | 12 +- Bitkit/Services/PrivatePaykitService.swift | 2 +- Bitkit/Services/PublicPaykitService.swift | 50 +++- Bitkit/ViewModels/ActivityListViewModel.swift | 2 +- Bitkit/ViewModels/NavigationViewModel.swift | 2 + Bitkit/ViewModels/SettingsViewModel.swift | 5 + Bitkit/ViewModels/WalletViewModel.swift | 49 +++- Bitkit/Views/Contacts/AddContactView.swift | 14 +- .../Views/Contacts/ContactActivityView.swift | 3 +- Bitkit/Views/Contacts/ContactDetailView.swift | 14 +- Bitkit/Views/Profile/PayContactsView.swift | 9 +- .../General/PaymentPreferenceView.swift | 248 ++++++++++++++++++ .../Views/Settings/GeneralSettingsView.swift | 29 ++ .../Wallets/Activity/ActivityItemView.swift | 79 +++++- .../Views/Wallets/Activity/ActivityRow.swift | 16 +- .../Activity/ActivityRowLightning.swift | 10 +- .../Wallets/Activity/ActivityRowOnchain.swift | 10 +- .../Activity/AssignActivityContactView.swift | 69 +++++ .../Views/Wallets/Send/LnurlPayAmount.swift | 6 +- .../Views/Wallets/Send/LnurlPayConfirm.swift | 6 +- .../Views/Wallets/Send/SendAmountView.swift | 6 +- .../Wallets/Send/SendConfirmationView.swift | 80 ++++-- .../Send/SendContactHeaderAvatar.swift | 23 ++ .../Wallets/Send/SendContactSelectView.swift | 103 ++++++++ .../Views/Wallets/Send/SendOptionsView.swift | 4 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 3 + BitkitTests/PrivatePaykitServiceTests.swift | 14 +- BitkitTests/PublicPaykitServiceTests.swift | 14 + changelog.d/next/539.added.md | 1 + 43 files changed, 1080 insertions(+), 132 deletions(-) create mode 100644 Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json create mode 100644 Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg create mode 100644 Bitkit/Components/PubkyContactAvatar.swift create mode 100644 Bitkit/Components/PubkyContactRow.swift create mode 100644 Bitkit/Views/Settings/General/PaymentPreferenceView.swift create mode 100644 Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift create mode 100644 Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift create mode 100644 Bitkit/Views/Wallets/Send/SendContactSelectView.swift create mode 100644 changelog.d/next/539.added.md diff --git a/.gitignore b/.gitignore index 68d7db447..be326f68b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ buildServer.json # AIs .ai/ +.codex/ .claude/*.local* diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index ed3072b5f..d5bda3c6a 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -907,7 +907,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc5"; + version = "0.1.0-rc6"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 920344ed9..9983b8172 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "04759b7b9bca7a0dd8a29e724f11e984db361241", - "version" : "0.1.0-rc5" + "revision" : "424b7703c7a83df3a0f2553274a76ed815659211", + "version" : "0.1.0-rc6" } }, { diff --git a/Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json new file mode 100644 index 000000000..83fa61d16 --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/user-minus.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "user-minus.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg b/Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg new file mode 100644 index 000000000..eb18c8ebf --- /dev/null +++ b/Bitkit/Assets.xcassets/icons/user-minus.imageset/user-minus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Bitkit/Components/PubkyContactAvatar.swift b/Bitkit/Components/PubkyContactAvatar.swift new file mode 100644 index 000000000..6de9069c7 --- /dev/null +++ b/Bitkit/Components/PubkyContactAvatar.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct PubkyContactAvatar: View { + let name: String + let imageUrl: String? + let size: CGFloat + + init(name: String, imageUrl: String?, size: CGFloat) { + self.name = name + self.imageUrl = imageUrl + self.size = size + } + + init(contact: PubkyContact, size: CGFloat) { + name = contact.displayName + imageUrl = contact.profile.imageUrl + self.size = size + } + + var body: some View { + Group { + if let imageUrl { + PubkyImage(uri: imageUrl, size: size) + } else { + ContactAvatarLetter(source: name, size: size) + } + } + .accessibilityHidden(true) + } +} diff --git a/Bitkit/Components/PubkyContactRow.swift b/Bitkit/Components/PubkyContactRow.swift new file mode 100644 index 000000000..f1c95b754 --- /dev/null +++ b/Bitkit/Components/PubkyContactRow.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct PubkyContactRow: View { + let contact: PubkyContact + var verticalPadding: CGFloat = 12 + var showsDivider = true + var isLoading = false + let action: () -> Void + + var body: some View { + VStack(spacing: 0) { + Button(action: action) { + HStack(spacing: 16) { + PubkyContactAvatar(contact: contact, size: 48) + + VStack(alignment: .leading, spacing: 4) { + CaptionText(contact.profile.truncatedPublicKey.localizedUppercase) + .lineLimit(1) + + BodyMSBText(contact.displayName) + .lineLimit(1) + } + + Spacer() + + if isLoading { + ProgressView() + } + } + .padding(.vertical, verticalPadding) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLoading) + .accessibilityLabel(contact.displayName) + + if showsDivider { + CustomDivider() + } + } + } +} diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index b8e12f975..570b8e26f 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -386,6 +386,7 @@ struct MainNavView: View { case .contactsIntro: ContactsIntroView() case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey) case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey) + case let .assignActivityContact(activityId): AssignActivityContactView(activityId: activityId) case .contactImportOverview: if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { missingPendingImportView(fallbackRoute: fallbackRoute) @@ -450,6 +451,7 @@ struct MainNavView: View { case .widgetsSettings: WidgetsSettingsScreen() case .notifications: NotificationsSettings() case .notificationsIntro: NotificationsIntro() + case .paymentPreference: PaymentPreferenceView() // Security settings case .changePin: ChangePinScreen() diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 8b111fe5d..9da7090f6 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -766,7 +766,8 @@ class PubkyProfileManager: ObservableObject { } private static func clearPublicPaykitSharingState() { - UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints") + UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") PrivatePaykitService.setContactSharingCleanupPending(false) UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index dc51a52c1..130f2965a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -816,7 +816,7 @@ "settings__adv__pp_header" = "Choose how you prefer to receive money when users send funds to your profile key."; "settings__adv__pp_footer" = "* This requires sharing payment data."; "settings__adv__pp_drag" = "Payment preference (drag to reorder)"; -"settings__adv__pp_contacts" = "Pay to/from contacts"; +"settings__adv__pp_contacts" = "Payments from contacts"; "settings__adv__pp_contacts_switch" = "Enable payments with contacts*"; "settings__adv__address_viewer" = "Address Viewer"; "settings__adv__sweep_funds" = "Sweep Funds"; @@ -1416,3 +1416,13 @@ "widgets__weather__current_fee" = "Current average fee"; "widgets__weather__next_block" = "Next block inclusion"; "widgets__weather__error" = "Couldn\'t get current fee weather"; + +"settings__adv__pp_options" = "Payment options"; +"settings__adv__pp_lightning" = "Lightning (Bitkit)"; +"settings__adv__pp_onchain" = "On-chain (Bitkit)"; +"settings__adv__pp_private_contacts" = "Private payments with contacts"; +"settings__adv__pp_public_contacts" = "Public payments with contacts*"; +"settings__adv__pp_public_footer" = "*Public payments with contacts requires payment data to be shared publicly."; +"settings__adv__pp_both" = "Both"; +"settings__adv__pp_lightning_short" = "Lightning"; +"settings__adv__pp_onchain_short" = "On-chain"; diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 77b94cdb9..c87af3eaf 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -3,11 +3,22 @@ import Foundation // MARK: - Saved Contacts extension PrivatePaykitService { - func prepareSavedContacts(_ publicKeys: [String], wallet: WalletViewModel) async { + @discardableResult + func prepareSavedContacts( + _ publicKeys: [String], + wallet: WalletViewModel, + requireImmediatePublication: Bool = false + ) async -> Error? { let publicKeys = rememberSavedContacts(publicKeys, replacing: true) - guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil } await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() - await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 3, reason: "prepare") + return await publishLocalEndpoints( + for: publicKeys, + wallet: wallet, + maxAdvanceSteps: 3, + reason: "prepare", + requireImmediatePublication: requireImmediatePublication + ) } func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async { @@ -29,8 +40,11 @@ extension PrivatePaykitService { } func removePublishedEndpoints() async throws { + try await removePublishedEndpoints(for: Array(state.contacts.keys)) + } + + func removePublishedEndpoints(for publicKeys: [String]) async throws { invalidateLinkEstablishmentWork() - let publicKeys = Array(state.contacts.keys) var firstError: Error? for publicKey in publicKeys { @@ -97,8 +111,14 @@ extension PrivatePaykitService { guard UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return } do { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) - try await removePublishedEndpoints() + if !UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey) { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + } + + let publicKeys = pendingPrivateEndpointRemovalKeys(savedPublicKeys: savedPublicKeys) + if !publicKeys.isEmpty { + try await removePublishedEndpoints(for: publicKeys) + } await clearUnsavedContactState(savedPublicKeys: savedPublicKeys) Self.setContactSharingCleanupPending(false) } catch { @@ -106,6 +126,15 @@ extension PrivatePaykitService { } } + func pendingPrivateEndpointRemovalKeys(savedPublicKeys publicKeys: [String]) -> [String] { + if !UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) { + return Array(state.contacts.keys) + } + + let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) + return state.contacts.keys.filter { !savedKeys.contains($0) } + } + func clearUnsavedContactState(savedPublicKeys publicKeys: [String]) async { let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) } @@ -117,6 +146,7 @@ extension PrivatePaykitService { await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys)) } + @discardableResult func publishLocalEndpoints( for publicKeys: [String], wallet: WalletViewModel, @@ -124,19 +154,27 @@ extension PrivatePaykitService { reason: String, scheduleRetries: Bool = true, forceLocalPublishWhenRemoteEmpty: Bool = false, - forceRefreshLightning: Bool = false - ) async { + forceRefreshLightning: Bool = false, + requireImmediatePublication: Bool = false + ) async -> Error? { let generation = stateGeneration + var firstError: Error? + for publicKey in publicKeys { + var retryPublicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey do { guard let normalizedKey = knownSavedContact(publicKey) else { continue } + retryPublicKey = normalizedKey guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) else { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } + if requireImmediatePublication, shouldRequirePrivateEndpointRemoval(publicKey: normalizedKey), firstError == nil { + firstError = PrivatePaykitError.privateUnavailable + } continue } @@ -161,6 +199,9 @@ extension PrivatePaykitService { do { fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) } catch { + if requireImmediatePublication, firstError == nil { + firstError = error + } if shouldCountAsStaleLinkFailure(error) { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) @@ -188,6 +229,8 @@ extension PrivatePaykitService { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } + } else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } else { cancelPendingPublicationRetry(for: normalizedKey) } @@ -198,6 +241,9 @@ extension PrivatePaykitService { do { fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) } catch { + if requireImmediatePublication, firstError == nil { + firstError = error + } if shouldCountAsStaleLinkFailure(error) { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) @@ -214,7 +260,7 @@ extension PrivatePaykitService { continue } - // Recovery retries may need to send the same map again if restored Noise counters diverged. + // Recovery retries may need to resend the same map after a link is re-established and remote state is empty. let shouldForcePublish = forceLocalPublishWhenRemoteEmpty && fetchedCount == 0 && state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false @@ -230,16 +276,26 @@ extension PrivatePaykitService { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } + } else if scheduleRetries, await shouldRetryMissingPrivateLightningEndpoint(for: normalizedKey, wallet: wallet) { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } else { cancelPendingPublicationRetry(for: normalizedKey) } } catch { + if scheduleRetries { + schedulePendingPublicationRetry(for: retryPublicKey, wallet: wallet) + } + if firstError == nil { + firstError = error + } Logger.warn( - "Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + "Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(retryPublicKey)): \(error)", context: "PrivatePaykit" ) } } + + return firstError } func schedulePendingPublicationRetry( @@ -274,9 +330,13 @@ extension PrivatePaykitService { ) let contactState = state.contacts[publicKey] - let needsAnotherRetry = contactState?.linkCompletedAt == nil || + let needsAnotherRetryFromLinkState = contactState?.linkCompletedAt == nil || contactState?.lastLocalPayloadHash == nil || contactState?.remoteEndpoints.isEmpty != false + var needsAnotherRetry = needsAnotherRetryFromLinkState + if !needsAnotherRetry { + needsAnotherRetry = await shouldRetryMissingPrivateLightningEndpoint(for: publicKey, wallet: wallet) + } if needsAnotherRetry { schedulePendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts - 1) } @@ -361,7 +421,6 @@ extension PrivatePaykitService { try await PubkyService.setPrivatePayments(linkId: linkId, entries: removalEntries) try ensureCurrentGeneration(generation) state.contacts[publicKey]?.lastLocalPayloadHash = nil - state.contacts[publicKey]?.localInvoice = nil try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized) if let ownPublicKey { diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index dbfff5f2d..0921af12e 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -22,7 +22,11 @@ extension PrivatePaykitService { for publicKey in matchingContacts { rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey) + if state.contacts[publicKey]?.localInvoice?.paymentHash == paymentHash { + state.contacts[publicKey]?.localInvoice = nil + } } + persistState() guard await canPublishPrivateEndpoints(wallet: wallet) else { return } @@ -202,12 +206,27 @@ extension PrivatePaykitService { isKnownSavedContact(publicKey) else { return } try ensureCurrentGeneration(generation) - let endpoints = try await buildLocalEndpoints( - for: publicKey, - wallet: wallet, - generation: generation, - forceRefreshLightning: forceRefreshLightning - ) + let endpoints: [PublicPaykitService.Endpoint] + do { + endpoints = try await buildLocalEndpoints( + for: publicKey, + wallet: wallet, + generation: generation, + forceRefreshLightning: forceRefreshLightning + ) + } catch let error as PrivatePaykitError { + guard case .privateUnavailable = error else { + throw error + } + try await publishLocalEndpointRemovalTombstone( + to: publicKey, + linkId: linkId, + wallet: wallet, + generation: generation, + force: force + ) + return + } try ensureCurrentGeneration(generation) guard !endpoints.isEmpty else { return } @@ -235,24 +254,60 @@ extension PrivatePaykitService { persistState() } + func publishLocalEndpointRemovalTombstone( + to publicKey: String, + linkId: String, + wallet: WalletViewModel, + generation: UInt64, + force: Bool + ) async throws { + guard shouldRequirePrivateEndpointRemoval(publicKey: publicKey) else { return } + + let entries = privateEndpointRemovalEntries() + try validateNoisePayload(entries: entries) + let payloadHash = localPayloadHash(entries: entries) + guard force || state.contacts[publicKey]?.lastLocalPayloadHash != payloadHash else { + return + } + + try ensureCurrentGeneration(generation) + guard await canPublishPrivateEndpoints(wallet: wallet), + isKnownSavedContact(publicKey) + else { return } + + do { + try await PubkyService.setPrivatePayments(linkId: linkId, entries: entries) + try ensureCurrentGeneration(generation) + } catch { + await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) + throw error + } + + try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) + state.contacts[publicKey, default: ContactState()].lastLocalPayloadHash = payloadHash + persistState() + } + func buildLocalEndpoints(for publicKey: String, wallet: WalletViewModel, generation: UInt64, forceRefreshLightning: Bool = false) async throws -> [PublicPaykitService.Endpoint] { var endpoints: [PublicPaykitService.Endpoint] = [] - let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) - try ensureCurrentGeneration(generation) - let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress) - endpoints.append( - PublicPaykitService.Endpoint( - methodId: PublicPaykitService.onchainMethodId(for: reservedAddress), - value: reservedAddress, - min: nil, - max: nil, - rawPayload: onchainPayload + if PublicPaykitService.isOnchainPaymentOptionEnabled() { + let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) + try ensureCurrentGeneration(generation) + let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress) + endpoints.append( + PublicPaykitService.Endpoint( + methodId: PublicPaykitService.onchainMethodId(for: reservedAddress), + value: reservedAddress, + min: nil, + max: nil, + rawPayload: onchainPayload + ) ) - ) + } - if await walletHasUsableChannels(wallet) { + if PublicPaykitService.isLightningPaymentOptionEnabled(), await walletHasUsableChannels(wallet) { do { let invoice = try await currentOrRotatedInvoice( for: publicKey, @@ -273,17 +328,20 @@ extension PrivatePaykitService { ) } catch { try ensureCurrentGeneration(generation) - state.contacts[publicKey]?.localInvoice = nil - persistState() + if let privateError = error as? PrivatePaykitError, + case .routeHintsUnavailable = privateError + { + schedulePendingPublicationRetry(for: publicKey, wallet: wallet) + } Logger.warn( "Failed to prepare private Paykit Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only: \(error)", context: "PrivatePaykit" ) } - } else { - try ensureCurrentGeneration(generation) - state.contacts[publicKey]?.localInvoice = nil - persistState() + } + + guard !endpoints.isEmpty else { + throw PrivatePaykitError.privateUnavailable } return endpoints @@ -315,8 +373,6 @@ extension PrivatePaykitService { } try validateNoisePayload(entries: onchainOnlyEntries) - state.contacts[publicKey]?.localInvoice = nil - persistState() Logger.warn( "Private Paykit endpoint map is too large with Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only.", context: "PrivatePaykit" diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift index 217888225..fd9cfbf47 100644 --- a/Bitkit/Services/PrivatePaykitService+Errors.swift +++ b/Bitkit/Services/PrivatePaykitService+Errors.swift @@ -6,6 +6,7 @@ enum PrivatePaykitError: LocalizedError { case privateUnavailable case payloadTooLarge case staleLinkState + case routeHintsUnavailable var errorDescription: String? { switch self { @@ -15,6 +16,8 @@ enum PrivatePaykitError: LocalizedError { "The private Paykit payload is too large." case .staleLinkState: "The private Paykit link state changed." + case .routeHintsUnavailable: + "A reachable private Lightning endpoint is not available yet." } } } @@ -73,8 +76,6 @@ extension PrivatePaykitService { "decrypt", "decryption", "cipher", - "noise state", - "counter", "invalid tag", "bad mac", ].contains { lowercasedReason.contains($0) } diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index d493263b1..4ffb0be55 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -22,6 +22,9 @@ extension PrivatePaykitService { guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { throw PublicPaykitError.invalidPayload } + guard PublicPaykitService.hasLightningRouteHints(bolt11: bolt11) else { + throw PrivatePaykitError.routeHintsUnavailable + } let invoice = StoredInvoice( bolt11: bolt11, @@ -55,7 +58,8 @@ extension PrivatePaykitService { await !isReceivedInvoiceSettled(paymentHash: invoice.paymentHash), case let .lightning(decodedInvoice) = try? await decode(invoice: invoice.bolt11), !decodedInvoice.isExpired, - decodedInvoice.amountSatoshis == 0 + decodedInvoice.amountSatoshis == 0, + PublicPaykitService.hasLightningRouteHints(bolt11: invoice.bolt11) else { return nil } @@ -80,6 +84,16 @@ extension PrivatePaykitService { wallet.hasUsableChannels } + func shouldRetryMissingPrivateLightningEndpoint(for publicKey: String, wallet: WalletViewModel) async -> Bool { + guard PublicPaykitService.isLightningPaymentOptionEnabled(), + await walletHasUsableChannels(wallet) + else { + return false + } + + return await reusablePrivateInvoice(for: publicKey) == nil + } + @MainActor func canPublishPrivateEndpoints(wallet: WalletViewModel) -> Bool { UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) && diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index dd716963f..5ce05066e 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -207,6 +207,15 @@ extension PrivatePaykitService { continue } + guard PublicPaykitService.hasLightningRouteHints(bolt11: endpoint.value) else { + staleLightningPaymentHashes.insert(paymentHash) + Logger.warn( + "Ignoring private Paykit Lightning endpoint without route hints from \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) + continue + } + if await hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) { staleLightningPaymentHashes.insert(paymentHash) Logger.warn( @@ -286,8 +295,7 @@ extension PrivatePaykitService { guard !remoteEntries.isEmpty else { // Paykit returns an empty map when there are no unread private-payment messages. - // Keep the cached map in that case; the current rc5 API cannot distinguish - // "no unread update" from a peer intentionally publishing an empty map. + // Keep the cached map in that case so transient empty reads do not drop the last known endpoints. return 0 } diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 1ddcd1e5f..342f56260 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -16,7 +16,7 @@ actor PrivatePaykitService { static let invoiceRefreshBufferSeconds: TimeInterval = 30 * 60 static let maxReceivedInvoicePaymentHashesPerContact = 100 static let staleLinkFailureThreshold = 3 - static let publishingEnabledKey = "sharesPublicPaykitEndpoints" + static let publishingEnabledKey = "sharesPrivatePaykitEndpoints" static let cleanupPendingKey = "paykitContactSharingCleanupPending" static let cacheStateKey = "privatePaykitCacheState" static let privateEndpointRemovalPayload = #"{"value":""}"# diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index dd1b54d5e..97d94e998 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -6,6 +6,7 @@ enum PublicPaykitError: LocalizedError { case noSupportedEndpoint case walletNotReady case invalidPayload + case routeHintsUnavailable var errorDescription: String? { switch self { @@ -15,6 +16,8 @@ enum PublicPaykitError: LocalizedError { return "Bitkit could not prepare a public payment endpoint because the wallet is not ready." case .invalidPayload: return "The public payment endpoint payload is invalid." + case .routeHintsUnavailable: + return "A reachable Lightning payment endpoint is not available yet." } } } @@ -69,6 +72,10 @@ private actor PublicPaykitEndpointLock { enum PublicPaykitService { private static let endpointLock = PublicPaykitEndpointLock() + static let publishingEnabledKey = "sharesPublicPaykitEndpoints" + static let lightningPaymentOptionEnabledKey = "paykitPaymentOptionLightningEnabled" + static let onchainPaymentOptionEnabledKey = "paykitPaymentOptionOnchainEnabled" + enum MethodId: String, Hashable, CaseIterable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" case bitcoinLightningLnurl = "btc-lightning-lnurl" @@ -233,13 +240,13 @@ enum PublicPaykitService { return } - let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true) + let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true, requireEndpoint: true) try await applyPublishedEndpoints(desiredEndpoints) } @MainActor static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws { - let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false) + let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false, requireEndpoint: false) try await applyPublishedEndpoints(desiredEndpoints) } @@ -323,6 +330,22 @@ enum PublicPaykitService { MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } } + static func isLightningPaymentOptionEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: lightningPaymentOptionEnabledKey) as? Bool ?? true + } + + static func isOnchainPaymentOptionEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: onchainPaymentOptionEnabledKey) as? Bool ?? true + } + + static func hasLightningRouteHints(bolt11: String) -> Bool { + guard let invoice = try? Bolt11Invoice.fromStr(invoiceStr: bolt11) else { + return false + } + + return invoice.routeHints().contains { !$0.isEmpty } + } + static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) return EndpointSyncPlan( @@ -400,7 +423,10 @@ enum PublicPaykitService { } @MainActor - private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] { + private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool, requireEndpoint: Bool) async throws -> [Endpoint] { + let includeOnchain = isOnchainPaymentOptionEnabled() + let includeLightning = isLightningPaymentOptionEnabled() + if refreshIfNeeded { let isNodeReady = await wallet.waitForNodeToRun() let lifecycleState = wallet.nodeLifecycleState @@ -408,14 +434,22 @@ enum PublicPaykitService { throw PublicPaykitError.walletNotReady } - _ = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: true) + _ = try await wallet.refreshPublicPaykitEndpoints( + forceRefreshBolt11: includeLightning, + includeOnchain: includeOnchain, + includeLightning: includeLightning + ) } - let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: false) + let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints( + forceRefreshBolt11: false, + includeOnchain: includeOnchain, + includeLightning: includeLightning + ) var endpoints: [Endpoint] = [] let onchainAddress = publicEndpoints.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines) - if !onchainAddress.isEmpty { + if includeOnchain, !onchainAddress.isEmpty { try endpoints.append( Endpoint( methodId: onchainMethodId(for: onchainAddress), @@ -428,7 +462,7 @@ enum PublicPaykitService { } let bolt11 = publicEndpoints.bolt11.trimmingCharacters(in: .whitespacesAndNewlines) - if !bolt11.isEmpty { + if includeLightning, !bolt11.isEmpty { try endpoints.append( Endpoint( methodId: .bitcoinLightningBolt11, @@ -440,7 +474,7 @@ enum PublicPaykitService { ) } - guard !endpoints.isEmpty else { + guard !endpoints.isEmpty || !requireEndpoint else { throw PublicPaykitError.noSupportedEndpoint } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index eab398541..0d95b2273 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -276,7 +276,7 @@ class ActivityListViewModel: ObservableObject { try await coreService.activity.get(contact: publicKey, sortDirection: .desc) } - func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { + func setContact(_ contactPublicKey: String?, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { if syncLdkPayments { try? await syncLdkNodePayments() } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index d293a33a7..b7670abcc 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -13,6 +13,7 @@ enum Route: Hashable { case contactsIntro case contactDetail(publicKey: String) case contactActivity(publicKey: String) + case assignActivityContact(activityId: String) case contactImportOverview case contactImportSelect case addContact(publicKey: String) @@ -76,6 +77,7 @@ enum Route: Hashable { case quickpayIntro case notifications case notificationsIntro + case paymentPreference // Security case dataBackups diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index b59fa7575..afd49b015 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -204,6 +204,11 @@ class SettingsViewModel: NSObject, ObservableObject { quickpayAmount = 5 enableNotifications = false enableNotificationsAmount = false + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + UserDefaults.standard.set(false, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") + UserDefaults.standard.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey) + UserDefaults.standard.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey) ignoresSwitchUnitToast = false ignoresHideBalanceToast = false pinFailedAttempts = 0 diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 47429cdfa..58aec99f5 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -59,7 +59,7 @@ class WalletViewModel: ObservableObject { private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false - @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false + @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60 private static let paykitChannelUsabilityRefreshDelay: UInt64 = 5_000_000_000 @@ -1004,20 +1004,34 @@ class WalletViewModel: ObservableObject { return onchainAddress } - func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) { - let publicOnchainAddress = try await refreshReusableOnchainAddress() + func refreshPublicPaykitEndpoints( + forceRefreshBolt11: Bool = false, + includeOnchain: Bool = true, + includeLightning: Bool = true + ) async throws -> (onchainAddress: String, bolt11: String) { + let publicOnchainAddress = includeOnchain ? try await refreshReusableOnchainAddress() : "" - if hasUsableChannels { + if includeLightning, hasUsableChannels { let hasReusableInvoice = await hasReusablePublicPaykitInvoice() let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice if shouldRefreshBolt11 { - try await refreshPublicPaykitBolt11() + do { + try await refreshPublicPaykitBolt11() + } catch let error as PublicPaykitError { + guard case .routeHintsUnavailable = error else { + throw error + } + Logger.warn( + "Public Paykit Lightning invoice has no route hints yet; publishing without Lightning for now", + context: "WalletViewModel" + ) + } } - } else { + } else if includeLightning { clearPublicPaykitBolt11() } - return (publicOnchainAddress, publicPaykitBolt11) + return (publicOnchainAddress, includeLightning ? publicPaykitBolt11 : "") } func refreshPublicPaykitEndpointsOnForeground() async { @@ -1042,8 +1056,16 @@ class WalletViewModel: ObservableObject { await refreshAndSyncState() try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning) - if sharesPublicPaykitEndpoints, hasUsableChannels { - await syncPublicPaykitEndpointsAfterChannelBecameUsable() + if sharesPublicPaykitEndpoints { + do { + if hasUsableChannels { + await syncPublicPaykitEndpointsAfterChannelBecameUsable() + } else { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + } + } catch { + Logger.warn("Failed to refresh public Paykit endpoints after channel availability changed: \(error)", context: "WalletViewModel") + } } await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( @@ -1058,7 +1080,10 @@ class WalletViewModel: ObservableObject { guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false } guard case let .lightning(lightningInvoice) = try? await decode(invoice: publicPaykitBolt11) else { return false } - return !lightningInvoice.isExpired && lightningInvoice.amountSatoshis == 0 && (lightningInvoice.description ?? "").isEmpty + return !lightningInvoice.isExpired && + lightningInvoice.amountSatoshis == 0 && + (lightningInvoice.description ?? "").isEmpty && + PublicPaykitService.hasLightningRouteHints(bolt11: publicPaykitBolt11) } private func refreshPublicPaykitBolt11() async throws { @@ -1067,6 +1092,10 @@ class WalletViewModel: ObservableObject { clearPublicPaykitBolt11() throw PublicPaykitError.invalidPayload } + guard PublicPaykitService.hasLightningRouteHints(bolt11: invoice) else { + clearPublicPaykitBolt11() + throw PublicPaykitError.routeHintsUnavailable + } publicPaykitBolt11 = invoice publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 8fc403e45..5fc567790 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -271,14 +271,7 @@ struct AddContactView: View { switch result { case let .opened(paymentRequest): - guard await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey) else { - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_not_opened_msg") - ) - return - } + _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey) case .noEndpoint, .notOpened: if let messageKey = result.contactPaymentFailureMessageKey { app.toast( @@ -304,6 +297,11 @@ struct AddContactView: View { try await app.handleScannedData(paymentRequest) } catch { Logger.warn("Failed to decode contact payment request: \(error)", context: "AddContactView") + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) return false } diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index 78f47995d..22bb52f68 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -64,7 +64,8 @@ struct ContactActivityView: View { ActivityRow( item: activity, feeEstimates: feeEstimatesManager.estimates, - contact: activityContact + contact: activityContact, + showContactAvatar: false ) } .accessibilityIdentifier("ContactActivity-\(index)") diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index aa6668d1f..f98c66b1c 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -271,14 +271,7 @@ struct ContactDetailView: View { switch result { case let .opened(paymentRequest): - guard await openContactPayment(paymentRequest: paymentRequest) else { - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_not_opened_msg") - ) - return - } + _ = await openContactPayment(paymentRequest: paymentRequest) case .noEndpoint, .notOpened: if let messageKey = result.contactPaymentFailureMessageKey { app.toast( @@ -304,6 +297,11 @@ struct ContactDetailView: View { try await app.handleScannedData(paymentRequest) } catch { Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView") + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) return false } diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 1653d9cbd..1a37190ce 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -2,7 +2,8 @@ import SwiftUI struct PayContactsView: View { @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false - @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false + @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false + @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false @EnvironmentObject var app: AppViewModel @EnvironmentObject var contactsManager: ContactsManager @@ -62,7 +63,7 @@ struct PayContactsView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { - enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + enablePayments = hasConfirmedPublicPaykitEndpoints ? (sharesPrivatePaykitEndpoints || sharesPublicPaykitEndpoints) : true } } @@ -74,6 +75,7 @@ struct PayContactsView: View { do { if publish { try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) + sharesPrivatePaykitEndpoints = true sharesPublicPaykitEndpoints = true hasConfirmedPublicPaykitEndpoints = true PrivatePaykitService.setContactSharingCleanupPending(false) @@ -83,6 +85,7 @@ struct PayContactsView: View { ) } else { var cleanupError: Error? + sharesPrivatePaykitEndpoints = false sharesPublicPaykitEndpoints = false hasConfirmedPublicPaykitEndpoints = true do { @@ -107,7 +110,7 @@ struct PayContactsView: View { } navigation.path = [.profile] } catch { - enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + enablePayments = hasConfirmedPublicPaykitEndpoints ? (sharesPrivatePaykitEndpoints || sharesPublicPaykitEndpoints) : true Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView") app.toast( type: .error, diff --git a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift new file mode 100644 index 000000000..f81670156 --- /dev/null +++ b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift @@ -0,0 +1,248 @@ +import SwiftUI + +struct PaymentPreferenceView: View { + @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false + @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true + @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true + @AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false + @AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false + + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var pubkyProfile: PubkyProfileManager + @EnvironmentObject private var wallet: WalletViewModel + + @State private var isUpdatingPaymentOptions = false + @State private var isUpdatingPrivate = false + @State private var isUpdatingPublic = false + + private var lightningOptionToggle: Binding { + Binding( + get: { lightningPaymentOptionEnabled }, + set: { value in + Task { await updatePaymentOption(.lightning, enabled: value) } + } + ) + } + + private var onchainOptionToggle: Binding { + Binding( + get: { onchainPaymentOptionEnabled }, + set: { value in + Task { await updatePaymentOption(.onchain, enabled: value) } + } + ) + } + + private var privateToggle: Binding { + Binding( + get: { sharesPrivatePaykitEndpoints }, + set: { value in + Task { await updatePrivateSharing(value) } + } + ) + } + + private var publicToggle: Binding { + Binding( + get: { sharesPublicPaykitEndpoints }, + set: { value in + Task { await updatePublicSharing(value) } + } + ) + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("settings__adv__payment_preference")) + .padding(.horizontal, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + BodyMText(t("settings__adv__pp_header"), textColor: .white64) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 32) + .padding(.bottom, 16) + + SettingsSectionHeader(t("settings__adv__pp_options").localizedUppercase) + + SettingsRow( + title: t("settings__adv__pp_lightning"), + rightIcon: nil, + toggle: lightningOptionToggle, + disabled: isUpdatingPaymentOptions || (lightningPaymentOptionEnabled && !onchainPaymentOptionEnabled), + testIdentifier: "LightningPaymentOptionToggle" + ) + + SettingsRow( + title: t("settings__adv__pp_onchain"), + rightIcon: nil, + toggle: onchainOptionToggle, + disabled: isUpdatingPaymentOptions || (onchainPaymentOptionEnabled && !lightningPaymentOptionEnabled), + testIdentifier: "OnchainPaymentOptionToggle" + ) + + if pubkyProfile.isAuthenticated { + SettingsSectionHeader(t("settings__adv__pp_contacts").localizedUppercase) + .padding(.top, 16) + + SettingsRow( + title: t("settings__adv__pp_private_contacts"), + rightIcon: nil, + toggle: privateToggle, + disabled: isUpdatingPrivate, + testIdentifier: "PrivateContactPaymentsToggle" + ) + + SettingsRow( + title: t("settings__adv__pp_public_contacts"), + rightIcon: nil, + toggle: publicToggle, + disabled: isUpdatingPublic, + testIdentifier: "PublicContactPaymentsToggle" + ) + } + + Spacer(minLength: 220) + + if pubkyProfile.isAuthenticated { + BodySText(t("settings__adv__pp_public_footer"), textColor: .white64) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + .background(Color.customBlack) + .navigationBarHidden(true) + } + + private func updatePrivateSharing(_ enabled: Bool) async { + guard !isUpdatingPrivate else { return } + guard !enabled || pubkyProfile.isAuthenticated else { + showProfileRequiredError() + return + } + isUpdatingPrivate = true + sharesPrivatePaykitEndpoints = enabled + hasConfirmedPublicPaykitEndpoints = true + defer { isUpdatingPrivate = false } + + if enabled { + PrivatePaykitService.setContactSharingCleanupPending(false) + await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) + } else { + do { + try await PrivatePaykitService.shared.removePublishedEndpoints() + PrivatePaykitService.setContactSharingCleanupPending(false) + } catch { + PrivatePaykitService.setContactSharingCleanupPending(true) + Logger.warn("Deferred private contact payment cleanup after disable failed: \(error)", context: "PaymentPreferenceView") + } + } + } + + private func updatePublicSharing(_ enabled: Bool) async { + guard !isUpdatingPublic else { return } + guard !enabled || pubkyProfile.isAuthenticated else { + showProfileRequiredError() + return + } + isUpdatingPublic = true + let previousValue = sharesPublicPaykitEndpoints + sharesPublicPaykitEndpoints = enabled + hasConfirmedPublicPaykitEndpoints = true + defer { isUpdatingPublic = false } + + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: enabled) + } catch { + sharesPublicPaykitEndpoints = previousValue + Logger.error("Failed to update public contact payments: \(error)", context: "PaymentPreferenceView") + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + } + } + + private enum PaymentOption { + case lightning + case onchain + } + + private func updatePaymentOption(_ option: PaymentOption, enabled: Bool) async { + guard !isUpdatingPaymentOptions else { return } + guard enabled || canDisablePaymentOption(option) else { return } + + isUpdatingPaymentOptions = true + let previousLightning = lightningPaymentOptionEnabled + let previousOnchain = onchainPaymentOptionEnabled + + switch option { + case .lightning: + lightningPaymentOptionEnabled = enabled + case .onchain: + onchainPaymentOptionEnabled = enabled + } + + defer { isUpdatingPaymentOptions = false } + + do { + try await refreshPublishedPaymentOptions() + } catch { + lightningPaymentOptionEnabled = previousLightning + onchainPaymentOptionEnabled = previousOnchain + await refreshPublishedPaymentOptionsBestEffort() + Logger.error("Failed to update payment options: \(error)", context: "PaymentPreferenceView") + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + } + } + + private func canDisablePaymentOption(_ option: PaymentOption) -> Bool { + switch option { + case .lightning: + return onchainPaymentOptionEnabled + case .onchain: + return lightningPaymentOptionEnabled + } + } + + private func showProfileRequiredError() { + app.toast(type: .error, title: t("common__error"), description: t("profile__session_expired_description")) + } + + private func refreshPublishedPaymentOptions() async throws { + if sharesPublicPaykitEndpoints { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) + } + + if sharesPrivatePaykitEndpoints { + if let privatePublishError = await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet, + requireImmediatePublication: true + ) { + throw privatePublishError + } + } + } + + private func refreshPublishedPaymentOptionsBestEffort() async { + if sharesPublicPaykitEndpoints { + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) + } catch { + Logger.warn("Failed to restore public payment options after preference rollback: \(error)", context: "PaymentPreferenceView") + } + } + + if sharesPrivatePaykitEndpoints { + await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) + } + } +} diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift index 413f970c9..92727e6df 100644 --- a/Bitkit/Views/Settings/GeneralSettingsView.swift +++ b/Bitkit/Views/Settings/GeneralSettingsView.swift @@ -1,8 +1,12 @@ import SwiftUI struct GeneralSettingsView: View { + @AppStorage(PublicPaykitService.lightningPaymentOptionEnabledKey) private var lightningPaymentOptionEnabled = true + @AppStorage(PublicPaykitService.onchainPaymentOptionEnabledKey) private var onchainPaymentOptionEnabled = true + @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var tagManager: TagManager @StateObject private var languageManager = LanguageManager.shared @@ -73,6 +77,17 @@ struct GeneralSettingsView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("TransactionSpeedSettings") + if pubkyProfile.isAuthenticated { + NavigationLink(value: Route.paymentPreference) { + SettingsRow( + title: t("settings__adv__payment_preference"), + iconName: "list-dashes", + rightText: paymentPreferenceSummary + ) + } + .accessibilityIdentifier("PaymentPreferenceSettings") + } + NavigationLink(value: app.hasSeenQuickpayIntro ? Route.quickpay : Route.quickpayIntro) { SettingsRow( title: t("settings__quickpay__nav_title"), @@ -96,6 +111,19 @@ struct GeneralSettingsView: View { .bottomSafeAreaPadding() } } + + private var paymentPreferenceSummary: String { + switch (lightningPaymentOptionEnabled, onchainPaymentOptionEnabled) { + case (true, true): + t("settings__adv__pp_both") + case (true, false): + t("settings__adv__pp_lightning_short") + case (false, true): + t("settings__adv__pp_onchain_short") + case (false, false): + t("common__off") + } + } } #Preview { @@ -103,6 +131,7 @@ struct GeneralSettingsView: View { GeneralSettingsView() .environmentObject(SettingsViewModel.shared) .environmentObject(CurrencyViewModel()) + .environmentObject(PubkyProfileManager()) .environmentObject(AppViewModel()) } .preferredColorScheme(.dark) diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 8bc37e36b..9107b5bc3 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -7,6 +7,7 @@ struct ActivityItemView: View { @EnvironmentObject var activityList: ActivityListViewModel @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var sheets: SheetViewModel @@ -99,6 +100,10 @@ struct ActivityItemView: View { return activity.channelId } + private var assignedContact: PubkyContact? { + viewModel.activity.contact(in: contactsManager.contacts) + } + private var navigationTitle: String { if isTransfer { return isTransferToSpending @@ -206,7 +211,7 @@ struct ActivityItemView: View { statusSection timestampSection feeSection - tagsSection + contactTagsSection note buttons @@ -410,7 +415,52 @@ struct ActivityItemView: View { } @ViewBuilder - private var tagsSection: some View { + private var contactTagsSection: some View { + if assignedContact != nil, !viewModel.tags.isEmpty { + HStack(alignment: .top, spacing: 16) { + contactCell + .frame(maxWidth: .infinity, alignment: .leading) + + tagsCell + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if assignedContact != nil { + contactCell + } else if !viewModel.tags.isEmpty { + tagsCell + } + } + + @ViewBuilder + private var contactCell: some View { + if let assignedContact { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("wallet__activity_contact")) + .padding(.bottom, 8) + + Button { + navigation.navigate(.contactDetail(publicKey: assignedContact.publicKey)) + } label: { + HStack(spacing: 8) { + PubkyContactAvatar(contact: assignedContact, size: 24) + BodySSBText(assignedContact.displayName) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.gray6) + .cornerRadius(8) + } + .buttonStyle(.plain) + .padding(.bottom, 16) + + Divider() + } + } + } + + @ViewBuilder + private var tagsCell: some View { if !viewModel.tags.isEmpty { VStack(alignment: .leading, spacing: 0) { CaptionMText(t("wallet__tags")) @@ -459,15 +509,20 @@ struct ActivityItemView: View { VStack(spacing: 16) { HStack(spacing: 16) { CustomButton( - title: t("wallet__activity_assign"), size: .small, - icon: Image("user-plus") + title: assignedContact == nil ? t("wallet__activity_assign") : t("wallet__activity_detach"), size: .small, + icon: Image(assignedContact == nil ? "user-plus" : "user-minus") .foregroundColor(accentColor), - isDisabled: true, shouldExpand: true ) { - // TODO: add assign contact action - Logger.info("Assign contact action not implemented") + if assignedContact == nil { + navigation.navigate(.assignActivityContact(activityId: viewModel.activityId)) + } else { + Task { + await detachContact() + } + } } + .accessibilityIdentifier(assignedContact == nil ? "ActivityAssignContact" : "ActivityDetachContact") CustomButton( title: t("wallet__activity_tag"), size: .small, @@ -528,6 +583,16 @@ struct ActivityItemView: View { } .frame(maxWidth: .infinity) } + + private func detachContact() async { + do { + try await activityList.setContact(nil, forPaymentId: viewModel.activityId) + await viewModel.refreshActivity() + } catch { + Logger.error("Failed to detach contact from activity \(viewModel.activityId): \(error)", context: "ActivityItemView") + app.toast(type: .error, title: t("contacts__error_saving"), description: error.localizedDescription) + } + } } struct ZigzagDivider: View { diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift index 5cd91bcc4..6e8f83952 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift @@ -6,12 +6,14 @@ struct ActivityRow: View { let feeEstimates: FeeRates? let contact: PubkyContact? let titleOverride: String? + let showContactAvatar: Bool - init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) { + init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil, showContactAvatar: Bool = true) { self.item = item self.feeEstimates = feeEstimates self.contact = contact self.titleOverride = titleOverride + self.showContactAvatar = showContactAvatar } private var rowTitleOverride: String? { @@ -51,13 +53,21 @@ struct ActivityRow: View { } } + private var rowContactAvatar: PubkyContact? { + guard showContactAvatar, contactTitle != nil else { + return nil + } + + return contact + } + var body: some View { Group { switch item { case let .lightning(activity): - ActivityRowLightning(item: activity, titleOverride: rowTitleOverride) + ActivityRowLightning(item: activity, contact: rowContactAvatar, titleOverride: rowTitleOverride) case let .onchain(activity): - ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: rowTitleOverride) + ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, contact: rowContactAvatar, titleOverride: rowTitleOverride) } } .padding(16) diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift index 6b338291a..de2ee65b0 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift @@ -3,10 +3,12 @@ import SwiftUI struct ActivityRowLightning: View { let item: LightningActivity + let contact: PubkyContact? let titleOverride: String? - init(item: LightningActivity, titleOverride: String? = nil) { + init(item: LightningActivity, contact: PubkyContact? = nil, titleOverride: String? = nil) { self.item = item + self.contact = contact self.titleOverride = titleOverride } @@ -47,7 +49,11 @@ struct ActivityRowLightning: View { var body: some View { HStack(spacing: 16) { - ActivityIcon(activity: .lightning(item), size: 40, context: .row) + if let contact { + PubkyContactAvatar(contact: contact, size: 40) + } else { + ActivityIcon(activity: .lightning(item), size: 40, context: .row) + } VStack(alignment: .leading, spacing: 2) { BodyMSBText(status).lineLimit(1) diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index 0149a4448..3695b2fca 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -4,11 +4,13 @@ import SwiftUI struct ActivityRowOnchain: View { let item: OnchainActivity let feeEstimates: FeeRates? + let contact: PubkyContact? let titleOverride: String? - init(item: OnchainActivity, feeEstimates: FeeRates?, titleOverride: String? = nil) { + init(item: OnchainActivity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) { self.item = item self.feeEstimates = feeEstimates + self.contact = contact self.titleOverride = titleOverride } @@ -78,7 +80,11 @@ struct ActivityRowOnchain: View { var body: some View { HStack(spacing: 16) { - ActivityIcon(activity: .onchain(item), size: 40, isCpfpChild: isCpfpChild, context: .row) + if let contact { + PubkyContactAvatar(contact: contact, size: 40) + } else { + ActivityIcon(activity: .onchain(item), size: 40, isCpfpChild: isCpfpChild, context: .row) + } VStack(alignment: .leading, spacing: 2) { BodyMSBText(status).lineLimit(1) diff --git a/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift b/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift new file mode 100644 index 000000000..ee32f2d58 --- /dev/null +++ b/Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct AssignActivityContactView: View { + @EnvironmentObject private var activityList: ActivityListViewModel + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var navigation: NavigationViewModel + + let activityId: String + @State private var selectedContactKey: String? + + private var contacts: [PubkyContact] { + contactsManager.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("slashtags__contact_assign")) + .padding(.horizontal, 16) + + if contacts.isEmpty { + Spacer() + BodyMText(t("slashtags__contacts_no_found"), textColor: .white64) + Spacer() + } else { + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 0) { + CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) + .padding(.bottom, 16) + + CustomDivider() + + ForEach(contacts) { contact in + PubkyContactRow( + contact: contact, + verticalPadding: 24, + isLoading: selectedContactKey == contact.publicKey + ) { + Task { + await assign(contact) + } + } + .accessibilityIdentifier("AssignContact-\(contact.publicKey)") + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.customBlack) + .navigationBarHidden(true) + } + + private func assign(_ contact: PubkyContact) async { + guard selectedContactKey == nil else { return } + selectedContactKey = contact.publicKey + defer { selectedContactKey = nil } + + do { + try await activityList.setContact(contact.publicKey, forPaymentId: activityId) + navigation.navigateBack() + } catch { + Logger.error("Failed to assign contact to activity \(activityId): \(error)", context: "AssignActivityContactView") + app.toast(type: .error, title: t("contacts__error_saving"), description: error.localizedDescription) + } + } +} diff --git a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift index a0ac94b3d..dc1b46832 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayAmount.swift @@ -24,7 +24,11 @@ struct LnurlPayAmount: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: t("wallet__lnurl_p_title"), showBackButton: true) + SheetHeader( + title: t("wallet__lnurl_p_title"), + showBackButton: true, + action: AnyView(SendContactHeaderAvatar()) + ) VStack(alignment: .leading, spacing: 0) { NumberPadTextField(viewModel: amountViewModel, testIdentifier: "SendNumberField") diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index d6672b271..2161ed0da 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -25,7 +25,11 @@ struct LnurlPayConfirm: View { var body: some View { VStack { - SheetHeader(title: t("wallet__lnurl_p_title"), showBackButton: true) + SheetHeader( + title: t("wallet__lnurl_p_title"), + showBackButton: true, + action: AnyView(SendContactHeaderAvatar()) + ) VStack(alignment: .leading) { MoneyStack( diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index f189e8b25..d81fe176e 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -54,7 +54,11 @@ struct SendAmountView: View { var body: some View { VStack(spacing: 0) { - SheetHeader(title: t("wallet__send_amount"), showBackButton: true) + SheetHeader( + title: t("wallet__send_amount"), + showBackButton: true, + action: AnyView(SendContactHeaderAvatar()) + ) VStack(alignment: .leading, spacing: 0) { NumberPadTextField(viewModel: amountViewModel, testIdentifier: "SendNumberField") diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 87368d56d..55547fc7b 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -4,6 +4,7 @@ import SwiftUI struct SendConfirmationView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var activityList: ActivityListViewModel + @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var settings: SettingsViewModel @@ -86,9 +87,21 @@ struct SendConfirmationView: View { return invoice.amountSatoshis == 0 } + private var contactPaymentContact: PubkyContact? { + guard let publicKey = app.contactPaymentContext?.publicKey else { + return nil + } + + return contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }) + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: t("wallet__send_review"), showBackButton: !navigationPath.isEmpty) + SheetHeader( + title: t("wallet__send_review"), + showBackButton: !navigationPath.isEmpty, + action: AnyView(SendContactHeaderAvatar()) + ) VStack(alignment: .leading, spacing: 0) { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -253,19 +266,25 @@ struct SendConfirmationView: View { .accessibilityIdentifier("SendConfirmAssetButton") } - Button { - navigateToManual(with: invoice.address) - } label: { + if let contact = contactPaymentContact { SendSectionView(t("wallet__send_to")) { - BodySSBText(invoice.address.ellipsis(maxLength: 18)) - .lineLimit(1) - .truncationMode(.middle) - .frame(height: 28) + contactRecipient(contact) + } + } else { + Button { + navigateToManual(with: invoice.address) + } label: { + SendSectionView(t("wallet__send_to")) { + BodySSBText(invoice.address.ellipsis(maxLength: 18)) + .lineLimit(1) + .truncationMode(.middle) + .frame(height: 28) + } } + .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .accessibilityIdentifier("ReviewUri") } - .frame(maxWidth: .infinity, alignment: .leading) - .buttonStyle(.plain) - .accessibilityIdentifier("ReviewUri") } .frame(maxWidth: .infinity, alignment: .leading) @@ -354,18 +373,24 @@ struct SendConfirmationView: View { Spacer(minLength: 16) - Button { - navigateToManual(with: invoice.bolt11) - } label: { + if let contact = contactPaymentContact { SendSectionView(t("wallet__send_to")) { - BodySSBText(invoice.bolt11.ellipsis(maxLength: 18)) - .lineLimit(1) - .truncationMode(.middle) - .frame(height: 28) + contactRecipient(contact) } + } else { + Button { + navigateToManual(with: invoice.bolt11) + } label: { + SendSectionView(t("wallet__send_to")) { + BodySSBText(invoice.bolt11.ellipsis(maxLength: 18)) + .lineLimit(1) + .truncationMode(.middle) + .frame(height: 28) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("ReviewUri") } - .buttonStyle(.plain) - .accessibilityIdentifier("ReviewUri") } HStack(alignment: .top, spacing: 16) { @@ -445,6 +470,17 @@ struct SendConfirmationView: View { } } + private func contactRecipient(_ contact: PubkyContact) -> some View { + HStack(spacing: 8) { + PubkyContactAvatar(contact: contact, size: 24) + + BodySSBText(contact.displayName) + .lineLimit(1) + } + .padding(.vertical, 2) + .accessibilityIdentifier("ReviewContactRecipient") + } + private func performPayment() async throws { var createdMetadataPaymentId: String? = nil let contactPublicKey = app.contactPaymentContext?.publicKey @@ -494,9 +530,7 @@ struct SendConfirmationView: View { // onTimeout callback already navigated to .pending; suppress throw return } catch { - if let contactPublicKey, - PrivatePaykitService.isDuplicatePaymentError(error) - { + if let contactPublicKey { await PrivatePaykitService.shared.discardRemoteLightningEndpoints( publicKey: contactPublicKey, paymentHashes: [paymentHash] diff --git a/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift new file mode 100644 index 000000000..301d2d6d0 --- /dev/null +++ b/Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct SendContactHeaderAvatar: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + + var body: some View { + if let contact { + PubkyContactAvatar(contact: contact, size: 32) + } else { + Spacer() + .frame(width: 24, height: 24) + } + } + + private var contact: PubkyContact? { + guard let publicKey = app.contactPaymentContext?.publicKey else { + return nil + } + + return contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }) + } +} diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift new file mode 100644 index 000000000..3ea377eba --- /dev/null +++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct SendContactSelectView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @Binding var navigationPath: [SendRoute] + @State private var selectedContactKey: String? + + private var contacts: [PubkyContact] { + contactsManager.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + } + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("wallet__recipient_contact"), showBackButton: true) + + if contacts.isEmpty { + Spacer() + BodyMText(t("slashtags__contacts_no_found"), textColor: .white64) + Spacer() + } else { + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 0) { + CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64) + .padding(.bottom, 16) + + CustomDivider() + + ForEach(contacts) { contact in + PubkyContactRow( + contact: contact, + verticalPadding: 24, + isLoading: selectedContactKey == contact.publicKey + ) { + Task { + await pay(contact) + } + } + .accessibilityIdentifier("SendContact-\(contact.publicKey)") + } + } + } + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .sheetBackground() + } + + private func pay(_ contact: PubkyContact) async { + guard selectedContactKey == nil else { return } + selectedContactKey = contact.publicKey + defer { selectedContactKey = nil } + + do { + let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: contact.publicKey, wallet: wallet) + + switch result { + case let .opened(paymentRequest): + _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: contact.publicKey) + case .noEndpoint, .notOpened: + if let messageKey = result.contactPaymentFailureMessageKey { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t(messageKey) + ) + } + } + } catch { + Logger.error("Failed to pay contact \(PubkyPublicKeyFormat.redacted(contact.publicKey)): \(error)", context: "SendContactSelectView") + app.toast(type: .error, title: t("slashtags__error_pay_title"), description: error.localizedDescription) + } + } + + @MainActor + private func openContactPayment(paymentRequest: String, publicKey: String) async -> Bool { + do { + try await app.handleScannedData(paymentRequest) + } catch { + Logger.warn("Failed to decode contact payment request: \(error)", context: "SendContactSelectView") + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) + return false + } + + guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { + return false + } + + app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + navigationPath.append(route) + return true + } +} diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift index 64904d404..e087f9dbd 100644 --- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift +++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift @@ -42,7 +42,6 @@ struct SendOptionsView: View { icon: "users", iconColor: .brandAccent, title: t("wallet__recipient_contact"), - isDisabled: true, testID: "RecipientContact" ) { handleContact() @@ -105,8 +104,7 @@ struct SendOptionsView: View { } func handleContact() { - // TODO: implement contacts - // navigationPath.append(.contact) + navigationPath.append(.contact) } } diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 844d99110..6fc4750ee 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -2,6 +2,7 @@ import SwiftUI enum SendRoute: Hashable { case options + case contact case manual case amount case utxoSelection @@ -291,6 +292,8 @@ struct SendSheet: View { switch route { case .options: SendOptionsView(navigationPath: $navigationPath) + case .contact: + SendContactSelectView(navigationPath: $navigationPath) case .manual: SendEnterManuallyView(navigationPath: $navigationPath) case .amount: diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 5bf8f12a1..3786c59a6 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -62,13 +62,15 @@ final class PrivatePaykitServiceTests: XCTestCase { func testStaleLinkFailureClassificationUsesTypedPaykitErrors() async { let service = PrivatePaykitService() - let noiseFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "noise state decrypt failed")) + let noiseFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "bad mac while decrypting payload")) let linkHandleFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Validation(reason: "Unknown encrypted-link handle: 123")) + let counterFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "counter mismatch")) let networkFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "connection timed out")) let sessionFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Session(reason: "No active session")) XCTAssertTrue(noiseFailure) XCTAssertTrue(linkHandleFailure) + XCTAssertFalse(counterFailure) XCTAssertFalse(networkFailure) XCTAssertFalse(sessionFailure) } @@ -98,6 +100,16 @@ final class PrivatePaykitServiceTests: XCTestCase { ) } + func testReceivedPrivateInvoiceHashKeepsContactAttribution() async { + let service = PrivatePaykitService() + let publicKey = "pubkycontact" + + await service.rememberReceivedInvoicePaymentHash("payment-hash", publicKey: publicKey) + + let matchedPublicKey = await service.contactPublicKey(forPrivateInvoicePaymentHash: "payment-hash") + XCTAssertEqual(matchedPublicKey, publicKey) + } + func testWalletBackupDecodesExistingPayloadWithoutPrivatePaykitFields() throws { let data = #"{"version":1,"createdAt":123,"transfers":[]}"#.data(using: .utf8)! let payload = try JSONDecoder().decode(WalletBackupV1.self, from: data) diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index f4593ae2f..06005e685 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -215,6 +215,20 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertTrue(plan.methodIdsToRemove.isEmpty) } + func testPublishedEndpointSyncPlanRemovesAllManagedEndpointsWhenDesiredSetIsEmpty() { + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingEndpoints: [ + .bitcoinLightningBolt11: #"{"value":"lnbc1old"}"#, + .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#, + .bitcoinOnchainP2wpkh: #"{"value":"bc1qold"}"#, + ], + desiredEndpoints: [] + ) + + XCTAssertTrue(plan.endpointsToSet.isEmpty) + XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinLightningBolt11, .bitcoinOnchainP2wpkh]) + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, diff --git a/changelog.d/next/539.added.md b/changelog.d/next/539.added.md new file mode 100644 index 000000000..936d76101 --- /dev/null +++ b/changelog.d/next/539.added.md @@ -0,0 +1 @@ +Added contact-first send, activity contact assignment, contact avatars in activity, and payment preference controls. From 7bb686391811b92946d6062ac949cdf43902bc05 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 19 May 2026 08:18:29 -0500 Subject: [PATCH 2/4] fix: hide private pay for ring auth --- Bitkit/Managers/PubkyProfileManager.swift | 19 ++++++++++ .../PrivatePaykitService+Invoices.swift | 16 +++++--- .../PrivatePaykitService+Payments.swift | 5 +++ Bitkit/Views/Profile/PayContactsView.swift | 17 ++++++--- .../General/PaymentPreferenceView.swift | 37 ++++++++++++++----- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 9da7090f6..92fe337a8 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -602,7 +602,9 @@ class PubkyProfileManager: ObservableObject { } do { + try? Keychain.delete(key: .pubkySecretKey) try Self.upsertKeychainString(.paykitSession, value: sessionSecret) + UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) Self.notifyAppStateBackupChanged() } catch { await PubkyService.forceSignOut() @@ -871,6 +873,23 @@ class PubkyProfileManager: ObservableObject { publicKey != nil } + var hasLocalSecretKeyForCurrentProfile: Bool { + Self.hasLocalSecretKey(for: publicKey) + } + + nonisolated static func hasLocalSecretKey(for publicKey: String?) -> Bool { + guard let publicKey, + let secretKeyHex = try? Keychain.loadString(key: .pubkySecretKey), + !secretKeyHex.isEmpty, + let rawPublicKey = try? PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: secretKeyHex) + else { + return false + } + + let prefixedPublicKey = rawPublicKey.hasPrefix("pubky") ? rawPublicKey : "pubky\(rawPublicKey)" + return PubkyPublicKeyFormat.matches(prefixedPublicKey, publicKey) + } + nonisolated static func snapshotSessionBackupState( loadKeychainString: (KeychainEntryType) throws -> String? = { try Keychain.loadString(key: $0) diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift index 4ffb0be55..ab6fae68f 100644 --- a/Bitkit/Services/PrivatePaykitService+Invoices.swift +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -95,11 +95,17 @@ extension PrivatePaykitService { } @MainActor - func canPublishPrivateEndpoints(wallet: WalletViewModel) -> Bool { - UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) && - UIApplication.shared.applicationState == .active && - wallet.walletExists == true && - wallet.nodeLifecycleState == .running + func canPublishPrivateEndpoints(wallet: WalletViewModel) async -> Bool { + guard UserDefaults.standard.bool(forKey: Self.publishingEnabledKey), + UIApplication.shared.applicationState == .active, + wallet.walletExists == true, + wallet.nodeLifecycleState == .running, + let ownPublicKey = await PubkyService.currentPublicKey() + else { + return false + } + + return PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) } @MainActor diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 5ce05066e..587d3c65e 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -111,6 +111,11 @@ extension PrivatePaykitService { guard let normalizedKey = knownSavedContact(publicKey) else { return try await PublicPaykitService.beginPayment(to: publicKey) } + guard let ownPublicKey = await PubkyService.currentPublicKey(), + PubkyProfileManager.hasLocalSecretKey(for: ownPublicKey) + else { + return try await PublicPaykitService.beginPayment(to: publicKey) + } do { let privateResult = try await beginPrivatePayment( diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 1a37190ce..683d91ae0 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -8,6 +8,7 @@ struct PayContactsView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var wallet: WalletViewModel @State private var enablePayments = true @@ -75,14 +76,17 @@ struct PayContactsView: View { do { if publish { try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) - sharesPrivatePaykitEndpoints = true + let canUsePrivateContactPayments = pubkyProfile.hasLocalSecretKeyForCurrentProfile + sharesPrivatePaykitEndpoints = canUsePrivateContactPayments sharesPublicPaykitEndpoints = true hasConfirmedPublicPaykitEndpoints = true - PrivatePaykitService.setContactSharingCleanupPending(false) - await PrivatePaykitService.shared.prepareSavedContacts( - contactsManager.contacts.map(\.publicKey), - wallet: wallet - ) + if canUsePrivateContactPayments { + PrivatePaykitService.setContactSharingCleanupPending(false) + await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) + } } else { var cleanupError: Error? sharesPrivatePaykitEndpoints = false @@ -127,6 +131,7 @@ struct PayContactsView: View { .environmentObject(AppViewModel()) .environmentObject(ContactsManager()) .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) diff --git a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift index f81670156..fb1bb8e61 100644 --- a/Bitkit/Views/Settings/General/PaymentPreferenceView.swift +++ b/Bitkit/Views/Settings/General/PaymentPreferenceView.swift @@ -52,6 +52,10 @@ struct PaymentPreferenceView: View { ) } + private var canUsePrivateContactPayments: Bool { + pubkyProfile.hasLocalSecretKeyForCurrentProfile + } + var body: some View { VStack(spacing: 0) { NavigationBar(title: t("settings__adv__payment_preference")) @@ -86,13 +90,15 @@ struct PaymentPreferenceView: View { SettingsSectionHeader(t("settings__adv__pp_contacts").localizedUppercase) .padding(.top, 16) - SettingsRow( - title: t("settings__adv__pp_private_contacts"), - rightIcon: nil, - toggle: privateToggle, - disabled: isUpdatingPrivate, - testIdentifier: "PrivateContactPaymentsToggle" - ) + if canUsePrivateContactPayments { + SettingsRow( + title: t("settings__adv__pp_private_contacts"), + rightIcon: nil, + toggle: privateToggle, + disabled: isUpdatingPrivate, + testIdentifier: "PrivateContactPaymentsToggle" + ) + } SettingsRow( title: t("settings__adv__pp_public_contacts"), @@ -116,6 +122,9 @@ struct PaymentPreferenceView: View { } .background(Color.customBlack) .navigationBarHidden(true) + .task(id: canUsePrivateContactPayments) { + await reconcilePrivateSharingAvailability() + } } private func updatePrivateSharing(_ enabled: Bool) async { @@ -124,6 +133,11 @@ struct PaymentPreferenceView: View { showProfileRequiredError() return } + guard !enabled || canUsePrivateContactPayments else { + sharesPrivatePaykitEndpoints = false + showProfileRequiredError() + return + } isUpdatingPrivate = true sharesPrivatePaykitEndpoints = enabled hasConfirmedPublicPaykitEndpoints = true @@ -167,6 +181,11 @@ struct PaymentPreferenceView: View { } } + private func reconcilePrivateSharingAvailability() async { + guard sharesPrivatePaykitEndpoints, !canUsePrivateContactPayments else { return } + sharesPrivatePaykitEndpoints = false + } + private enum PaymentOption { case lightning case onchain @@ -218,7 +237,7 @@ struct PaymentPreferenceView: View { try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) } - if sharesPrivatePaykitEndpoints { + if sharesPrivatePaykitEndpoints, canUsePrivateContactPayments { if let privatePublishError = await PrivatePaykitService.shared.prepareSavedContacts( contactsManager.contacts.map(\.publicKey), wallet: wallet, @@ -238,7 +257,7 @@ struct PaymentPreferenceView: View { } } - if sharesPrivatePaykitEndpoints { + if sharesPrivatePaykitEndpoints, canUsePrivateContactPayments { await PrivatePaykitService.shared.prepareSavedContacts( contactsManager.contacts.map(\.publicKey), wallet: wallet From b44508b08dc9a88a3834c8cb05dd8bb1ec324d1b Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 19 May 2026 09:15:00 -0500 Subject: [PATCH 3/4] fix: recover private paykit after profile recreation --- Bitkit/Managers/PubkyProfileManager.swift | 3 +- .../PrivatePaykitService+Backup.swift | 2 + .../PrivatePaykitService+Contacts.swift | 64 ++++++++++++++- .../Services/PrivatePaykitService+State.swift | 20 ++++- Bitkit/Services/PrivatePaykitService.swift | 9 ++ Bitkit/Views/Profile/EditProfileView.swift | 2 +- BitkitTests/PrivatePaykitServiceTests.swift | 82 +++++++++++++++++++ 7 files changed, 178 insertions(+), 4 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 92fe337a8..4f473efbc 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -605,6 +605,7 @@ class PubkyProfileManager: ObservableObject { try? Keychain.delete(key: .pubkySecretKey) try Self.upsertKeychainString(.paykitSession, value: sessionSecret) UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) + PrivatePaykitService.setProfileRecoveryPending(false) Self.notifyAppStateBackupChanged() } catch { await PubkyService.forceSignOut() @@ -755,7 +756,7 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { - await PrivatePaykitService.shared.closeAndClear() + await PrivatePaykitService.shared.closeAndClear(markProfileRecoveryPending: true) await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() await PubkyService.forceSignOut() try? Keychain.delete(key: .paykitSession) diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift index 2da1b7e57..08babc933 100644 --- a/Bitkit/Services/PrivatePaykitService+Backup.swift +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -41,6 +41,7 @@ extension PrivatePaykitService { guard let backup else { state = PrivatePaykitState(contacts: [:]) persistState() + Self.setProfileRecoveryPending(false) return } @@ -72,6 +73,7 @@ extension PrivatePaykitService { state = PrivatePaykitState(contacts: restoredContacts) persistState() + Self.setProfileRecoveryPending(false) } func validatedSnapshot( diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index c87af3eaf..99225d38e 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -11,6 +11,13 @@ extension PrivatePaykitService { ) async -> Error? { let publicKeys = rememberSavedContacts(publicKeys, replacing: true) guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil } + if Self.isProfileRecoveryPending, !publicKeys.isEmpty { + return await recoverSavedContactsAfterProfileRecreation( + publicKeys, + wallet: wallet, + requireImmediatePublication: requireImmediatePublication + ) + } await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() return await publishLocalEndpoints( for: publicKeys, @@ -21,6 +28,57 @@ extension PrivatePaykitService { ) } + @discardableResult + func recoverSavedContactsAfterProfileRecreation( + _ publicKeys: [String], + wallet: WalletViewModel, + requireImmediatePublication: Bool = false + ) async -> Error? { + let publicKeys = rememberSavedContacts(publicKeys, replacing: true) + guard !publicKeys.isEmpty else { return nil } + guard await canPublishPrivateEndpoints(wallet: wallet) else { return nil } + + invalidateLinkEstablishmentWork() + guard await purgePrivatePaymentOutboxForProfileRecovery(reason: "profile recovery") else { + Self.setProfileRecoveryPending(true) + return requireImmediatePublication ? PrivatePaykitError.privateUnavailable : nil + } + + let startedAt = UInt64(Date().timeIntervalSince1970) + for publicKey in publicKeys { + if let linkId = activeHandlesByContact[publicKey]?.linkId { + try? await PubkyService.closeEncryptedLink(linkId: linkId) + } + if let handshakeId = activeHandlesByContact[publicKey]?.handshakeId { + try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) + } + + markContactForProfileRecovery(publicKey, startedAt: startedAt) + } + + persistState(markWalletBackup: true) + Self.setProfileRecoveryPending(false) + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + + return await publishLocalEndpoints( + for: publicKeys, + wallet: wallet, + maxAdvanceSteps: 3, + reason: "profile recovery", + forceLocalPublishWhenRemoteEmpty: true, + requireImmediatePublication: requireImmediatePublication + ) + } + + func markContactForProfileRecovery(_ publicKey: String, startedAt: UInt64) { + activeHandlesByContact[publicKey] = ContactPaykitHandles() + + var contactState = ContactState() + contactState.recoveryStartedAt = startedAt + state.contacts[publicKey] = contactState + cancelPendingPublicationRetry(for: publicKey) + } + func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async { let publicKeys = rememberSavedContacts(publicKeys, replacing: true) guard await canPublishPrivateEndpoints(wallet: wallet) else { return } @@ -211,7 +269,10 @@ extension PrivatePaykitService { throw error } - guard await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else { + let shouldForcePublish = forceLocalPublishWhenRemoteEmpty && + fetchedCount == 0 && + state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false + guard shouldForcePublish || await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } @@ -223,6 +284,7 @@ extension PrivatePaykitService { linkId: linkId, wallet: wallet, generation: generation, + force: shouldForcePublish, forceRefreshLightning: forceRefreshLightning ) if fetchedCount == 0, state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false { diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index bfb91bd23..d10f75051 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -3,7 +3,15 @@ import Foundation // MARK: - Active Paykit Handles extension PrivatePaykitService { - func closeAndClear() async { + func markProfileRecoveryPendingIfNeeded() { + guard !state.contacts.isEmpty || !knownSavedContactKeys.isEmpty else { return } + Self.setProfileRecoveryPending(true) + } + + func closeAndClear(markProfileRecoveryPending: Bool = false) async { + if markProfileRecoveryPending { + markProfileRecoveryPendingIfNeeded() + } resetInFlightWork() await closeActivePaykitHandles() activeHandlesByContact.removeAll() @@ -195,6 +203,16 @@ extension PrivatePaykitService { return true } + return await purgePrivatePaymentStorage(reason: reason) + } + + @discardableResult + func purgePrivatePaymentOutboxForProfileRecovery(reason: String) async -> Bool { + await purgePrivatePaymentStorage(reason: reason) + } + + @discardableResult + private func purgePrivatePaymentStorage(reason: String) async -> Bool { guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), !sessionSecret.isEmpty else { return false } diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift index 342f56260..ff62ba16b 100644 --- a/Bitkit/Services/PrivatePaykitService.swift +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -18,6 +18,7 @@ actor PrivatePaykitService { static let staleLinkFailureThreshold = 3 static let publishingEnabledKey = "sharesPrivatePaykitEndpoints" static let cleanupPendingKey = "paykitContactSharingCleanupPending" + static let profileRecoveryPendingKey = "privatePaykitProfileRecoveryPending" static let cacheStateKey = "privatePaykitCacheState" static let privateEndpointRemovalPayload = #"{"value":""}"# static let recoveryMarkerStageInit = "init" @@ -57,4 +58,12 @@ actor PrivatePaykitService { static func setContactSharingCleanupPending(_ isPending: Bool) { UserDefaults.standard.set(isPending, forKey: cleanupPendingKey) } + + static func setProfileRecoveryPending(_ isPending: Bool) { + UserDefaults.standard.set(isPending, forKey: profileRecoveryPendingKey) + } + + static var isProfileRecoveryPending: Bool { + UserDefaults.standard.bool(forKey: profileRecoveryPendingKey) + } } diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index cc3d495fd..1f09f68df 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -86,7 +86,6 @@ struct EditProfileView: View { // MARK: - Avatar Picker - @ViewBuilder private var avatarPicker: some View { PhotosPicker(selection: $selectedPhotoItem, matching: .images) { avatarContent @@ -176,6 +175,7 @@ struct EditProfileView: View { } private func performDeleteProfile() async throws { + await PrivatePaykitService.shared.markProfileRecoveryPendingIfNeeded() try await contactsManager.deleteAllContacts() try await pubkyProfile.deleteProfile() navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 3786c59a6..638388402 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -217,4 +217,86 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertFalse(cacheJson.contains("secret-link")) XCTAssertFalse(cacheJson.contains("secret-handshake")) } + + func testCloseAndClearCanMarkProfileRecoveryPendingWhenPrivateContactStateExists() async { + let service = PrivatePaykitService() + let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + await service.restoreBackup([ + publicKey: PrivatePaykitContactLinkBackupV1( + publicKey: publicKey, + linkSnapshotHex: nil, + handshakeSnapshotHex: nil, + remoteEndpoints: [:], + linkCompletedAt: 123, + handshakeUpdatedAt: nil, + recoveryStartedAt: nil, + mainRecoveryAttemptId: nil, + responderRecoveryAttemptId: nil + ), + ]) + + PrivatePaykitService.setProfileRecoveryPending(false) + await service.closeAndClear(markProfileRecoveryPending: true) + defer { PrivatePaykitService.setProfileRecoveryPending(false) } + + XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) + } + + func testMarkProfileRecoveryPendingUsesPrivateContactStateBeforeContactDelete() async { + let service = PrivatePaykitService() + let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + await service.restoreBackup([ + publicKey: PrivatePaykitContactLinkBackupV1( + publicKey: publicKey, + linkSnapshotHex: nil, + handshakeSnapshotHex: nil, + remoteEndpoints: [:], + linkCompletedAt: 123, + handshakeUpdatedAt: nil, + recoveryStartedAt: nil, + mainRecoveryAttemptId: nil, + responderRecoveryAttemptId: nil + ), + ]) + + PrivatePaykitService.setProfileRecoveryPending(false) + await service.markProfileRecoveryPendingIfNeeded() + await service.pruneUnsavedContactState(savedPublicKeys: []) + defer { PrivatePaykitService.setProfileRecoveryPending(false) } + + XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) + let snapshot = await service.backupSnapshot() + XCTAssertNil(snapshot) + } + + func testProfileRecoveryStateClearsOldEndpointMetadata() async { + let service = PrivatePaykitService() + let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo" + let remoteEndpoints = [ + PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: #"{"value":"bcrt1qcached"}"#, + ] + await service.restoreBackup([ + publicKey: PrivatePaykitContactLinkBackupV1( + publicKey: publicKey, + linkSnapshotHex: nil, + handshakeSnapshotHex: nil, + remoteEndpoints: remoteEndpoints, + linkCompletedAt: 123, + handshakeUpdatedAt: 100, + recoveryStartedAt: nil, + mainRecoveryAttemptId: nil, + responderRecoveryAttemptId: nil + ), + ]) + + await service.markContactForProfileRecovery(publicKey, startedAt: 456) + let snapshot = await service.backupSnapshot()?[publicKey] + + XCTAssertEqual(snapshot?.recoveryStartedAt, 456) + XCTAssertNil(snapshot?.linkSnapshotHex) + XCTAssertNil(snapshot?.handshakeSnapshotHex) + XCTAssertEqual(snapshot?.remoteEndpoints, [:]) + XCTAssertNil(snapshot?.linkCompletedAt) + XCTAssertNil(snapshot?.handshakeUpdatedAt) + } } From a37da01fee71e7295af476d3bb5d38f0ab076806 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 19 May 2026 09:31:31 -0500 Subject: [PATCH 4/4] test: harden private paykit recovery --- .../PrivatePaykitService+Contacts.swift | 23 +++++++++++++++--- .../Services/PrivatePaykitService+State.swift | 2 +- BitkitTests/PrivatePaykitServiceTests.swift | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 99225d38e..95c304416 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -40,8 +40,7 @@ extension PrivatePaykitService { invalidateLinkEstablishmentWork() guard await purgePrivatePaymentOutboxForProfileRecovery(reason: "profile recovery") else { - Self.setProfileRecoveryPending(true) - return requireImmediatePublication ? PrivatePaykitError.privateUnavailable : nil + return handleProfileRecoveryPurgeFailure(requireImmediatePublication: requireImmediatePublication) } let startedAt = UInt64(Date().timeIntervalSince1970) @@ -70,6 +69,11 @@ extension PrivatePaykitService { ) } + func handleProfileRecoveryPurgeFailure(requireImmediatePublication: Bool) -> Error? { + Self.setProfileRecoveryPending(true) + return requireImmediatePublication ? PrivatePaykitError.privateUnavailable : nil + } + func markContactForProfileRecovery(_ publicKey: String, startedAt: UInt64) { activeHandlesByContact[publicKey] = ContactPaykitHandles() @@ -82,12 +86,20 @@ extension PrivatePaykitService { func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async { let publicKeys = rememberSavedContacts(publicKeys, replacing: true) guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + if Self.isProfileRecoveryPending, !publicKeys.isEmpty { + await recoverSavedContactsAfterProfileRecreation(publicKeys, wallet: wallet) + return + } await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 1, reason: "refresh") } func refreshKnownSavedContactEndpoints(wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false) async { guard !knownSavedContactKeys.isEmpty else { return } guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + if Self.isProfileRecoveryPending { + await recoverSavedContactsAfterProfileRecreation(Array(knownSavedContactKeys), wallet: wallet) + return + } await publishLocalEndpoints( for: Array(knownSavedContactKeys), wallet: wallet, @@ -272,7 +284,12 @@ extension PrivatePaykitService { let shouldForcePublish = forceLocalPublishWhenRemoteEmpty && fetchedCount == 0 && state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false - guard shouldForcePublish || await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else { + let shouldPublish = if shouldForcePublish { + true + } else { + await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) + } + guard shouldPublish else { if scheduleRetries { schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) } diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift index d10f75051..b12d31701 100644 --- a/Bitkit/Services/PrivatePaykitService+State.swift +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -4,7 +4,7 @@ import Foundation extension PrivatePaykitService { func markProfileRecoveryPendingIfNeeded() { - guard !state.contacts.isEmpty || !knownSavedContactKeys.isEmpty else { return } + guard !state.contacts.isEmpty else { return } Self.setProfileRecoveryPending(true) } diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 638388402..f19d81f27 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -269,6 +269,30 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertNil(snapshot) } + func testProfileRecoveryPurgeFailureKeepsMarkerPending() async { + let service = PrivatePaykitService() + + PrivatePaykitService.setProfileRecoveryPending(false) + let error = await service.handleProfileRecoveryPurgeFailure(requireImmediatePublication: false) + defer { PrivatePaykitService.setProfileRecoveryPending(false) } + + XCTAssertNil(error) + XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) + } + + func testProfileRecoveryPurgeFailureFailsImmediateMode() async { + let service = PrivatePaykitService() + + PrivatePaykitService.setProfileRecoveryPending(false) + let error = await service.handleProfileRecoveryPurgeFailure(requireImmediatePublication: true) + defer { PrivatePaykitService.setProfileRecoveryPending(false) } + + guard case .privateUnavailable = error as? PrivatePaykitError else { + return XCTFail("Expected privateUnavailable") + } + XCTAssertTrue(PrivatePaykitService.isProfileRecoveryPending) + } + func testProfileRecoveryStateClearsOldEndpointMetadata() async { let service = PrivatePaykitService() let publicKey = "pubkycytinw71a3ge1esmzj5e53hsr3jtj6t4pogpgr6k75w9mzmyokzo"