From d71e3c066ba7bb9e00cdcdde010cd05b2731440d Mon Sep 17 00:00:00 2001 From: benk10 Date: Sat, 25 Apr 2026 15:33:35 -0500 Subject: [PATCH 01/26] WIP: add public Paykit endpoints --- Bitkit.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 3 +- Bitkit/MainNavView.swift | 1 + .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Services/CoreService.swift | 68 +++- Bitkit/Services/MigrationsService.swift | 2 + Bitkit/Services/PublicPaykitService.swift | 316 ++++++++++++++++++ Bitkit/ViewModels/AppViewModel.swift | 7 + Bitkit/ViewModels/NavigationViewModel.swift | 1 + Bitkit/ViewModels/WalletViewModel.swift | 9 + Bitkit/Views/Contacts/AddContactView.swift | 59 ++++ .../Views/Contacts/ContactActivityView.swift | 135 ++++++++ Bitkit/Views/Contacts/ContactDetailView.swift | 60 ++++ Bitkit/Views/Gift/GiftLoading.swift | 1 + Bitkit/Views/Profile/PayContactsView.swift | 34 +- .../Activity/ActivityExplorerView.swift | 2 + .../Wallets/Activity/ActivityItemView.swift | 2 + .../Views/Wallets/Activity/ActivityRow.swift | 11 +- .../Activity/ActivityRowLightning.swift | 10 + .../Wallets/Activity/ActivityRowOnchain.swift | 11 + .../Wallets/Send/SendConfirmationView.swift | 20 +- Bitkit/Views/Wallets/Send/SendSheet.swift | 3 + Bitkit/Views/Wallets/Sheets/BoostSheet.swift | 1 + BitkitTests/ActivityListTest.swift | 13 + BitkitTests/PublicPaykitServiceTests.swift | 51 +++ CHANGELOG.md | 1 + 26 files changed, 818 insertions(+), 9 deletions(-) create mode 100644 Bitkit/Services/PublicPaykitService.swift create mode 100644 Bitkit/Views/Contacts/ContactActivityView.swift create mode 100644 BitkitTests/PublicPaykitServiceTests.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 09df5e0db..e4f6ee38f 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -936,8 +936,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { - kind = revision; - revision = 99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69; + kind = exactVersion; + version = 0.1.58; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 06a83812a..097ad5ed6 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "99bd86bb60c1f14e8ce8a6356cd2ab36f222fc69" + "revision" : "47bd506bb46ae885191a265f76245ab357a93f28", + "version" : "0.1.58" } }, { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 967ce388e..5f6b6387a 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -363,6 +363,7 @@ struct MainNavView: View { } case .contactsIntro: ContactsIntroView() case let .contactDetail(publicKey): ContactDetailView(publicKey: publicKey) + case let .contactActivity(publicKey): ContactActivityView(publicKey: publicKey) case .contactImportOverview: if let fallbackRoute = fallbackRouteForMissingPendingImport(hasPendingImport: contactsManager.hasPendingImport) { missingPendingImportView(fallbackRoute: fallbackRoute) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 766d3dfda..28741329e 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -958,6 +958,8 @@ "slashtags__onboarding_text" = "Get automatic updates from your Bitkit contacts, pay them, and follow their public profiles."; "slashtags__onboarding_button" = "Add First Contact"; "contacts__detail_title" = "Contact"; +"contacts__activity_sent_to" = "Sent to {name}"; +"contacts__activity_received_from" = "Received from {name}"; "contacts__detail_empty_state" = "Unable to load contact."; "contacts__empty_state" = "You don't have any contacts yet."; "contacts__intro_add_contact" = "Add Contact"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index d871d1092..546092987 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -6,6 +6,25 @@ import LDKNode // MARK: - Activity Service class ActivityService { + private enum PubkyContactKey { + private static let prefix = "pubky" + private static let rawKeyLength = 52 + private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") + + static func normalized(_ input: String) -> String? { + let boundedInput = String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(prefix.count + rawKeyLength)) + let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput + + guard rawKey.count == rawKeyLength, + rawKey.allSatisfy({ allowedCharacters.contains($0) }) + else { + return nil + } + + return "\(prefix)\(rawKey)" + } + } + private let coreService: CoreService private let activitiesChangedSubject = PassthroughSubject() @@ -395,6 +414,7 @@ class ActivityService { var isTransfer = existingOnchain?.isTransfer ?? false var channelId = existingOnchain?.channelId let transferTxId = existingOnchain?.transferTxId + let contact = existingOnchain?.contact let feeRate = existingOnchain?.feeRate ?? 1 let preservedAddress = existingOnchain?.address ?? "Loading..." let doesExist = existingOnchain?.doesExist ?? true @@ -466,6 +486,7 @@ class ActivityService { confirmTimestamp: blockTimestamp, channelId: channelId, transferTxId: transferTxId, + contact: contact, createdAt: UInt64(payment.creationTime.timeIntervalSince1970), updatedAt: paymentTimestamp, seenAt: seenAt @@ -691,6 +712,7 @@ class ActivityService { message: description ?? "", timestamp: paymentTimestamp, preimage: preimage, + contact: existingLightning?.contact, createdAt: paymentTimestamp, updatedAt: paymentTimestamp, seenAt: existingLightning?.seenAt @@ -960,6 +982,20 @@ class ActivityService { } } + func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] { + let normalizedKey = PubkyContactKey.normalized(publicKey) ?? publicKey + let activities = try await get(filter: .all, sortDirection: sortDirection) + + return activities.filter { activity in + switch activity { + case let .lightning(lightning): + return lightning.contact == normalizedKey + case let .onchain(onchain): + return onchain.contact == normalizedKey + } + } + } + func update(id: String, activity: Activity) async throws { try await ServiceQueue.background(.core) { try updateActivity(activityId: id, activity: activity) @@ -982,7 +1018,8 @@ class ActivityService { address: String, amount: UInt64, fee: UInt64, - feeRate: UInt32 + feeRate: UInt32, + contact: String? = nil ) async { do { try await ServiceQueue.background(.core) { @@ -1008,6 +1045,7 @@ class ActivityService { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: contact.map { PubkyContactKey.normalized($0) ?? $0 }, createdAt: now, updatedAt: now, seenAt: now @@ -1022,6 +1060,32 @@ class ActivityService { } } + func setContact(_ publicKey: String?, forActivity id: String) async throws { + let normalizedContact = publicKey.map { PubkyContactKey.normalized($0) ?? $0 } + + try await ServiceQueue.background(.core) { + guard let activity = try getActivityById(activityId: id) else { + return + } + + switch activity { + case var .lightning(lightning): + guard lightning.contact != normalizedContact else { return } + lightning.contact = normalizedContact + lightning.updatedAt = UInt64(Date().timeIntervalSince1970) + try updateActivity(activityId: id, activity: .lightning(lightning)) + self.activitiesChangedSubject.send() + + case var .onchain(onchain): + guard onchain.contact != normalizedContact else { return } + onchain.contact = normalizedContact + onchain.updatedAt = UInt64(Date().timeIntervalSince1970) + try updateActivity(activityId: id, activity: .onchain(onchain)) + self.activitiesChangedSubject.send() + } + } + } + func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { // Rebuild cache if deleting an onchain activity with boostTxIds @@ -1217,6 +1281,7 @@ class ActivityService { message: template.message, timestamp: timestamp, preimage: template.status == .succeeded ? "preimage\(activityId)" : nil, + contact: nil, createdAt: timestamp, updatedAt: timestamp, seenAt: nil @@ -1241,6 +1306,7 @@ class ActivityService { confirmTimestamp: template.confirmed == true ? timestamp + 3600 : nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: timestamp, updatedAt: timestamp, seenAt: nil diff --git a/Bitkit/Services/MigrationsService.swift b/Bitkit/Services/MigrationsService.swift index 13d847195..b3d3c5f3a 100644 --- a/Bitkit/Services/MigrationsService.swift +++ b/Bitkit/Services/MigrationsService.swift @@ -1404,6 +1404,7 @@ extension MigrationsService { message: item.message ?? "", timestamp: timestampSecs, preimage: item.preimage, + contact: nil, createdAt: timestampSecs, updatedAt: timestampSecs, seenAt: now @@ -1853,6 +1854,7 @@ extension MigrationsService { confirmTimestamp: item.confirmTimestamp.map { UInt64($0 / 1000) }, channelId: item.channelId, transferTxId: item.transferTxId, + contact: nil, createdAt: activityTimestamp, updatedAt: activityTimestamp, seenAt: now diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift new file mode 100644 index 000000000..bcfd7b737 --- /dev/null +++ b/Bitkit/Services/PublicPaykitService.swift @@ -0,0 +1,316 @@ +import BitkitCore +import Foundation +import LDKNode + +enum PublicPaykitError: LocalizedError { + case noSupportedEndpoint + case walletNotReady + case invalidPayload + + var errorDescription: String? { + switch self { + case .noSupportedEndpoint: + return "No supported public payment endpoint is available." + case .walletNotReady: + 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." + } + } +} + +enum PublicPaykitPaymentLaunchResult { + case opened + case noEndpoint + case notOpened +} + +enum PublicPaykitService { + enum MethodId: String, Hashable { + case bitcoinLightningBolt11 = "btc-lightning-bolt11" + case bitcoinLightningLnurl = "btc-lightning-lnurl-pay" + case bitcoinOnchainP2tr = "btc-bitcoin-p2tr" + case bitcoinOnchainP2wpkh = "btc-bitcoin-p2wpkh" + case bitcoinOnchainP2sh = "btc-bitcoin-p2sh" + case bitcoinOnchainP2pkh = "btc-bitcoin-p2pkh" + + static let payablePreferenceOrder: [MethodId] = [ + .bitcoinLightningBolt11, + .bitcoinLightningLnurl, + .bitcoinOnchainP2tr, + .bitcoinOnchainP2wpkh, + .bitcoinOnchainP2sh, + .bitcoinOnchainP2pkh, + ] + + static let publishableMethodIds: [MethodId] = [ + .bitcoinLightningBolt11, + .bitcoinOnchainP2tr, + .bitcoinOnchainP2wpkh, + .bitcoinOnchainP2sh, + .bitcoinOnchainP2pkh, + ] + } + + struct Endpoint: Equatable, Hashable { + let methodId: MethodId + let value: String + let min: String? + let max: String? + let rawPayload: String + + var paymentRequest: String { + value + } + } + + static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] { + let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) + var endpointsByMethodId: [MethodId: Endpoint] = [:] + + for entry in paymentEntries { + guard let endpoint = parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData) else { + continue + } + + endpointsByMethodId[endpoint.methodId] = endpoint + } + + return MethodId.payablePreferenceOrder.compactMap { endpointsByMethodId[$0] } + } + + static func parseEndpoint(methodId rawMethodId: String, endpointData: String) -> Endpoint? { + guard let methodId = MethodId(rawValue: rawMethodId) else { + return nil + } + + guard let payload = parsePayload(endpointData) else { + return nil + } + + return Endpoint( + methodId: methodId, + value: payload.value, + min: payload.min, + max: payload.max, + rawPayload: endpointData + ) + } + + static func serializePayload(value: String) throws -> String { + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { + throw PublicPaykitError.invalidPayload + } + + let payload = ["value": trimmedValue] + let data = try JSONSerialization.data(withJSONObject: payload) + guard let json = String(data: data, encoding: .utf8) else { + throw PublicPaykitError.invalidPayload + } + return json + } + + @MainActor + static func syncPublishedEndpoints(wallet: WalletViewModel, publish: Bool) async throws { + guard publish else { + await removePublishedEndpoints() + return + } + + let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: true) + try await applyPublishedEndpoints(desiredEndpoints) + } + + @MainActor + static func syncCurrentPublishedEndpoints(wallet: WalletViewModel) async throws { + let desiredEndpoints = try await buildWalletEndpoints(wallet: wallet, refreshIfNeeded: false) + try await applyPublishedEndpoints(desiredEndpoints) + } + + static func removePublishedEndpoints() async { + for methodId in MethodId.publishableMethodIds { + try? await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + } + } + + @MainActor + static func beginPayment( + to publicKey: String, + app: AppViewModel, + currency: CurrencyViewModel, + settings: SettingsViewModel, + sheets: SheetViewModel + ) async throws -> PublicPaykitPaymentLaunchResult { + let endpoints = try await fetchPublicEndpoints(publicKey: publicKey) + + guard let preferredEndpoint = await preferredPayableEndpoint(from: endpoints) else { + return endpoints.isEmpty ? .noEndpoint : .notOpened + } + + try await app.handleScannedData(preferredEndpoint.paymentRequest) + + guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { + return .notOpened + } + + app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + PaymentNavigationHelper.openPaymentSheet( + app: app, + currency: currency, + settings: settings, + sheetViewModel: sheets + ) + + return .opened + } + + static func onchainMethodId(for address: String) -> MethodId { + let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if normalizedAddress.hasPrefix("bc1p") || normalizedAddress.hasPrefix("tb1p") || normalizedAddress.hasPrefix("bcrt1p") { + return .bitcoinOnchainP2tr + } + + if normalizedAddress.hasPrefix("bc1q") || normalizedAddress.hasPrefix("tb1q") || normalizedAddress.hasPrefix("bcrt1q") { + return .bitcoinOnchainP2wpkh + } + + if normalizedAddress.hasPrefix("3") || normalizedAddress.hasPrefix("2") { + return .bitcoinOnchainP2sh + } + + return .bitcoinOnchainP2pkh + } + + private struct ParsedPayload { + let value: String + let min: String? + let max: String? + } + + private static func parsePayload(_ endpointData: String) -> ParsedPayload? { + let trimmedPayload = endpointData.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPayload.isEmpty else { + return nil + } + + if let data = trimmedPayload.data(using: .utf8), + let payloadObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = (payloadObject["value"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + { + return ParsedPayload( + value: value, + min: payloadObject["min"] as? String, + max: payloadObject["max"] as? String + ) + } + + return nil + } + + private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { + let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) + + for endpoint in desiredEndpoints { + try await PubkyService.setPaymentEndpoint( + methodId: endpoint.methodId.rawValue, + endpointData: endpoint.rawPayload + ) + } + + for methodId in MethodId.publishableMethodIds where !desiredMethodIds.contains(methodId) { + try? await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + } + } + + @MainActor + private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] { + if refreshIfNeeded { + let isNodeReady = await wallet.waitForNodeToRun() + let lifecycleState = wallet.nodeLifecycleState + guard isNodeReady || lifecycleState == .running else { + throw PublicPaykitError.walletNotReady + } + + try await wallet.refreshBip21(forceRefreshBolt11: true) + } + + var endpoints: [Endpoint] = [] + + let onchainAddress = wallet.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines) + if !onchainAddress.isEmpty { + try endpoints.append( + Endpoint( + methodId: onchainMethodId(for: onchainAddress), + value: onchainAddress, + min: nil, + max: nil, + rawPayload: serializePayload(value: onchainAddress) + ) + ) + } + + let bolt11 = wallet.bolt11.trimmingCharacters(in: .whitespacesAndNewlines) + if !bolt11.isEmpty { + try endpoints.append( + Endpoint( + methodId: .bitcoinLightningBolt11, + value: bolt11, + min: nil, + max: nil, + rawPayload: serializePayload(value: bolt11) + ) + ) + } + + guard !endpoints.isEmpty else { + throw PublicPaykitError.noSupportedEndpoint + } + + return endpoints + } + + private static func preferredPayableEndpoint(from endpoints: [Endpoint]) async -> Endpoint? { + for endpoint in endpoints { + if await isPayableEndpoint(endpoint) { + return endpoint + } + } + + return nil + } + + private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool { + switch endpoint.methodId { + case .bitcoinLightningBolt11: + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.paymentRequest) else { + return false + } + + guard !invoice.isExpired else { + return false + } + + let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType) + return !NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) + + case .bitcoinLightningLnurl: + guard case .lnurlPay = try? await decode(invoice: endpoint.paymentRequest) else { + return false + } + + return true + + case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh: + guard case let .onChain(invoice) = try? await decode(invoice: endpoint.paymentRequest) else { + return false + } + + let addressValidation = try? validateBitcoinAddress(address: invoice.address) + let addressNetwork = addressValidation.map { NetworkValidationHelper.convertNetworkType($0.network) } + return !NetworkValidationHelper.isNetworkMismatch(addressNetwork: addressNetwork, currentNetwork: Env.network) + } + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 07dc3be44..bbfb8ab01 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -10,6 +10,10 @@ struct SendSheetPendingResolution: Equatable { let success: Bool } +struct ContactPaymentContext: Equatable { + let publicKey: String +} + enum ManualEntryValidationResult: Equatable { case valid case empty @@ -28,6 +32,7 @@ class AppViewModel: ObservableObject { @Published var manualEntryInput: String = "" @Published var isManualEntryInputValid: Bool = false @Published var manualEntryValidationResult: ManualEntryValidationResult = .empty + @Published var contactPaymentContext: ContactPaymentContext? // LNURL @Published var lnurlPayData: LnurlPayData? @@ -586,6 +591,7 @@ extension AppViewModel { selectedWalletToPayFrom = .onchain // Reset to default lnurlPayData = nil lnurlWithdrawData = nil + contactPaymentContext = nil } } @@ -774,6 +780,7 @@ extension AppViewModel { message: "", timestamp: now, preimage: nil, + contact: nil, createdAt: now, updatedAt: nil, seenAt: nil diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index c26ea2e44..a2995bda7 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -12,6 +12,7 @@ enum Route: Hashable { case contacts case contactsIntro case contactDetail(publicKey: String) + case contactActivity(publicKey: String) case contactImportOverview case contactImportSelect case addContact(publicKey: String) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 782b82a17..e5a70aae2 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -56,6 +56,7 @@ class WalletViewModel: ObservableObject { private var probeOutcomes: [PaymentId: ProbeOutcome] = [:] @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false + @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false private let lightningService: LightningService private let coreService: CoreService @@ -992,6 +993,14 @@ class WalletViewModel: ObservableObject { // Persist metadata with migrated tags await persistPreActivityMetadata(tags: tagsToMigrate) + + if sharesPublicPaykitEndpoints { + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + } catch { + Logger.warn("Failed to refresh public paykit endpoints after receive refresh: \(error)", context: "WalletViewModel") + } + } } /// Payment hash from the current bolt11 invoice, if available diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 8a8ba0a30..46d4fa979 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -2,13 +2,17 @@ import SwiftUI struct AddContactView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var sheets: SheetViewModel let publicKey: String @State private var fetchedProfile: PubkyProfile? + @State private var hasPublicPaymentEndpoint = false @State private var isLoading = true @State private var isSaving = false @State private var errorMessage: String? @@ -144,6 +148,15 @@ struct AddContactView: View { .padding(.horizontal, 32) .padding(.bottom, 16) + if hasPublicPaymentEndpoint { + CustomButton(title: t("wallet__send"), variant: .secondary) { + await payContact() + } + .accessibilityIdentifier("AddContactPay") + .padding(.horizontal, 32) + .padding(.bottom, 16) + } + HStack(spacing: 16) { CustomButton(title: t("common__discard"), variant: .secondary) { navigation.navigateBack() @@ -215,6 +228,7 @@ struct AddContactView: View { } else { errorMessage = t("contacts__add_error") } + await loadPaymentEndpoints(publicKey: normalizedKey) } isLoading = false @@ -244,15 +258,60 @@ struct AddContactView: View { app.toast(type: .error, title: t("contacts__add_error"), description: error.localizedDescription) } } + + private func loadPaymentEndpoints(publicKey: String) async { + do { + let endpoints = try await PublicPaykitService.fetchPublicEndpoints(publicKey: publicKey) + hasPublicPaymentEndpoint = !endpoints.isEmpty + } catch { + Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "AddContactView") + hasPublicPaymentEndpoint = false + } + } + + private func payContact() async { + guard let normalizedPublicKey else { + app.toast(type: .warning, title: t("slashtags__error_pay_title"), description: t("slashtags__error_pay_empty_msg")) + return + } + + do { + let result = try await PublicPaykitService.beginPayment( + to: normalizedPublicKey, + app: app, + currency: currency, + settings: settings, + sheets: sheets + ) + + if case .noEndpoint = result { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_empty_msg") + ) + } + } catch { + Logger.error("Failed to pay public pubky \(publicKey): \(error)", context: "AddContactView") + app.toast( + type: .error, + title: t("slashtags__error_pay_title"), + description: error.localizedDescription + ) + } + } } #Preview { NavigationStack { AddContactView(publicKey: "pubkyz6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) .environmentObject(PubkyProfileManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift new file mode 100644 index 000000000..6d99067b5 --- /dev/null +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -0,0 +1,135 @@ +import BitkitCore +import SwiftUI + +struct ContactActivityView: View { + @EnvironmentObject private var activityList: ActivityListViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager + + let publicKey: String + + @State private var activities: [Activity] = [] + @State private var isLoading = true + @State private var contactName = "" + + private var groupedActivities: [ActivityGroupItem] { + activityList.groupActivities(activities) + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("wallet__activity")) + .padding(.horizontal, 16) + + if isLoading { + loadingContent + } else if groupedActivities.isEmpty { + emptyContent + } else { + activityContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + resolveContactName() + await loadActivities() + } + .onReceive(CoreService.shared.activity.activitiesChangedPublisher) { _ in + Task { + await loadActivities() + } + } + .onReceive(contactsManager.$contacts) { _ in + resolveContactName() + } + } + + private var activityContent: some View { + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(Array(zip(groupedActivities.indices, groupedActivities)), id: \.1) { index, groupItem in + switch groupItem { + case let .header(title): + CaptionMText(title) + .frame(height: 34, alignment: .bottom) + + case let .activity(activity): + NavigationLink(value: Route.activityDetail(activity)) { + ActivityRow( + item: activity, + feeEstimates: feeEstimatesManager.estimates, + titleOverride: activityTitle(activity) + ) + } + .accessibilityIdentifier("ContactActivity-\(index)") + } + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + + @ViewBuilder + private var loadingContent: some View { + VStack { + Spacer() + ActivityIndicator(size: 32) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var emptyContent: some View { + VStack(spacing: 16) { + Spacer() + BodyMText(t("wallet__activity_no")) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var contactDisplayName: String { + if !contactName.isEmpty { + return contactName + } + + return publicKey.ellipsis(maxLength: 18) + } + + private func resolveContactName() { + contactName = contactsManager.contacts.first(where: { $0.publicKey == publicKey })?.profile.name ?? "" + } + + private func activityTitle(_ activity: Activity) -> String { + let txType: PaymentType = switch activity { + case let .lightning(lightningActivity): + lightningActivity.txType + case let .onchain(onchainActivity): + onchainActivity.txType + } + + switch txType { + case .sent: + return t("contacts__activity_sent_to", variables: ["name": contactDisplayName]) + case .received: + return t("contacts__activity_received_from", variables: ["name": contactDisplayName]) + } + } + + private func loadActivities() async { + isLoading = true + defer { isLoading = false } + + do { + activities = try await CoreService.shared.activity.get(contact: publicKey, sortDirection: .desc) + } catch { + Logger.error(error, context: "ContactActivityView") + activities = [] + } + } +} diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index b021dca34..d689b6971 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -2,12 +2,16 @@ import SwiftUI struct ContactDetailView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var sheets: SheetViewModel let publicKey: String @State private var profile: PubkyProfile? + @State private var hasPublicPaymentEndpoint = false @State private var isLoading = true @State private var showAddTagSheet = false @State private var hasResolvedContactFromContacts = false @@ -33,6 +37,7 @@ struct ContactDetailView: View { profile = cached.profile hasResolvedContactFromContacts = true } + await loadPaymentEndpoints() isLoading = false } .onReceive(contactsManager.$contacts) { updatedContacts in @@ -91,6 +96,20 @@ struct ContactDetailView: View { @ViewBuilder private var contactActions: some View { HStack(spacing: 16) { + if hasPublicPaymentEndpoint { + GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) { + Task { + await payContact() + } + } + .accessibilityIdentifier("ContactPay") + } + + GradientCircleButton(icon: "activity", accessibilityLabel: t("wallet__activity")) { + navigation.navigate(.contactActivity(publicKey: publicKey)) + } + .accessibilityIdentifier("ContactActivity") + GradientCircleButton(icon: "copy", accessibilityLabel: t("common__copy")) { UIPasteboard.general.string = publicKey app.toast(type: .success, title: t("common__copied")) @@ -224,6 +243,7 @@ struct ContactDetailView: View { } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) { profile = fetched } + await loadPaymentEndpoints() } .accessibilityIdentifier("ContactRetry") Spacer() @@ -251,14 +271,54 @@ struct ContactDetailView: View { presentingVC.present(activityVC, animated: true) } } + + private func loadPaymentEndpoints() async { + do { + let endpoints = try await PublicPaykitService.fetchPublicEndpoints(publicKey: publicKey) + hasPublicPaymentEndpoint = !endpoints.isEmpty + } catch { + Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "ContactDetailView") + hasPublicPaymentEndpoint = false + } + } + + private func payContact() async { + do { + let result = try await PublicPaykitService.beginPayment( + to: publicKey, + app: app, + currency: currency, + settings: settings, + sheets: sheets + ) + + if case .noEndpoint = result { + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_empty_msg") + ) + } + } catch { + Logger.error("Failed to pay contact \(publicKey): \(error)", context: "ContactDetailView") + app.toast( + type: .error, + title: t("slashtags__error_pay_title"), + description: error.localizedDescription + ) + } + } } #Preview { NavigationStack { ContactDetailView(publicKey: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") .environmentObject(AppViewModel()) + .environmentObject(CurrencyViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(ContactsManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Gift/GiftLoading.swift b/Bitkit/Views/Gift/GiftLoading.swift index f8127444a..b903db4c3 100644 --- a/Bitkit/Views/Gift/GiftLoading.swift +++ b/Bitkit/Views/Gift/GiftLoading.swift @@ -110,6 +110,7 @@ struct GiftLoading: View { message: code, timestamp: nowTimestamp, preimage: nil, + contact: nil, createdAt: nowTimestamp, updatedAt: nil, seenAt: nil diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 9eeb03cfa..d239ed762 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -1,9 +1,15 @@ import SwiftUI struct PayContactsView: View { + @AppStorage("hasConfirmedPublicPaykitEndpoints") private var hasConfirmedPublicPaykitEndpoints = false + @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false + + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var wallet: WalletViewModel @State private var enablePayments = true + @State private var isSaving = false var body: some View { VStack(spacing: 0) { @@ -42,8 +48,8 @@ struct PayContactsView: View { .accessibilityIdentifier("PayContactsToggle") .padding(.horizontal, 32) - CustomButton(title: t("common__continue")) { - navigation.path = [.profile] + CustomButton(title: t("common__continue"), isLoading: isSaving) { + await continueFlow() } .accessibilityIdentifier("PayContactsContinue") .padding(.top, 16) @@ -53,13 +59,37 @@ struct PayContactsView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) + .onAppear { + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + } + } + + private func continueFlow() async { + isSaving = true + defer { isSaving = false } + + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: enablePayments) + sharesPublicPaykitEndpoints = enablePayments + hasConfirmedPublicPaykitEndpoints = true + navigation.path = [.profile] + } catch { + Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView") + app.toast( + type: .error, + title: t("common__error"), + description: error.localizedDescription + ) + } } } #Preview { NavigationStack { PayContactsView() + .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) + .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift index 21341cd26..b208e29b3 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift @@ -283,6 +283,7 @@ struct ActivityExplorer_Previews: PreviewProvider { message: "Test payment", timestamp: UInt64(Date().timeIntervalSince1970), preimage: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -310,6 +311,7 @@ struct ActivityExplorer_Previews: PreviewProvider { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 8053984a9..8bc37e36b 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -576,6 +576,7 @@ struct ActivityItemView_Previews: PreviewProvider { message: "Splitting the lunch bill. Thanks for suggesting that amazing restaurant!", timestamp: UInt64(Date().timeIntervalSince1970), preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -605,6 +606,7 @@ struct ActivityItemView_Previews: PreviewProvider { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift index a9b1a3a28..7d40630fb 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift @@ -4,14 +4,21 @@ import SwiftUI struct ActivityRow: View { let item: Activity let feeEstimates: FeeRates? + let titleOverride: String? + + init(item: Activity, feeEstimates: FeeRates?, titleOverride: String? = nil) { + self.item = item + self.feeEstimates = feeEstimates + self.titleOverride = titleOverride + } var body: some View { Group { switch item { case let .lightning(activity): - ActivityRowLightning(item: activity) + ActivityRowLightning(item: activity, titleOverride: titleOverride) case let .onchain(activity): - ActivityRowOnchain(item: activity, feeEstimates: feeEstimates) + ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: titleOverride) } } .padding(16) diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift index ba354cbb8..6b338291a 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift @@ -3,6 +3,12 @@ import SwiftUI struct ActivityRowLightning: View { let item: LightningActivity + let titleOverride: String? + + init(item: LightningActivity, titleOverride: String? = nil) { + self.item = item + self.titleOverride = titleOverride + } private var amountPrefix: String { return item.txType == .sent ? "-" : "+" @@ -21,6 +27,10 @@ struct ActivityRowLightning: View { } private var status: String { + if let titleOverride { + return titleOverride + } + switch item.status { case .failed: return t("wallet__activity_failed") diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index aa975570a..0149a4448 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -4,6 +4,13 @@ import SwiftUI struct ActivityRowOnchain: View { let item: OnchainActivity let feeEstimates: FeeRates? + let titleOverride: String? + + init(item: OnchainActivity, feeEstimates: FeeRates?, titleOverride: String? = nil) { + self.item = item + self.feeEstimates = feeEstimates + self.titleOverride = titleOverride + } @State private var isCpfpChild: Bool = false @@ -24,6 +31,10 @@ struct ActivityRowOnchain: View { } private var status: String { + if let titleOverride { + return titleOverride + } + if item.isTransfer { return item.confirmed ? t("wallet__activity_transfer") : t("wallet__activity_transferring") } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index bb24fec42..abe1d9247 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -472,6 +472,7 @@ struct SendConfirmationView: View { navigationPath.append(.pending(paymentHash: paymentHash)) } ) + await syncContactForActivity(paymentId: paymentHash) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { @@ -495,7 +496,8 @@ struct SendConfirmationView: View { address: invoice.address, amount: amount, fee: UInt64(transactionFee), - feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1 + feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, + contact: app.contactPaymentContext?.publicKey ) // Set the amount for the success screen @@ -526,6 +528,22 @@ struct SendConfirmationView: View { } } + private func syncContactForActivity(paymentId: String) async { + guard let contactPublicKey = app.contactPaymentContext?.publicKey else { + return + } + + if let payments = LightningService.shared.payments { + try? await CoreService.shared.activity.syncLdkNodePayments(payments) + } + + do { + try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentId) + } catch { + Logger.warn("Failed to set contact for activity \(paymentId): \(error)", context: "SendConfirmationView") + } + } + private func validatePayment() async -> [WarningType] { var warnings: [WarningType] = [] diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 3d3883ca7..f09fab6f1 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -108,6 +108,9 @@ struct SendSheet: View { } } } + .onDisappear { + app.contactPaymentContext = nil + } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. // This covers: diff --git a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift index c47e6555e..f514d47e2 100644 --- a/Bitkit/Views/Wallets/Sheets/BoostSheet.swift +++ b/Bitkit/Views/Wallets/Sheets/BoostSheet.swift @@ -443,6 +443,7 @@ struct BoostSheet: View { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index dcf40ed93..989b68eb3 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -42,6 +42,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -90,6 +91,7 @@ final class ActivityTests: XCTestCase { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -128,6 +130,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -166,6 +169,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 1", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -182,6 +186,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 2", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -224,6 +229,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 1", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -247,6 +253,7 @@ final class ActivityTests: XCTestCase { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -302,6 +309,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -322,6 +330,7 @@ final class ActivityTests: XCTestCase { message: "Updated test payment", timestamp: timestamp, preimage: "preimage123", + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -359,6 +368,7 @@ final class ActivityTests: XCTestCase { message: "Test payment", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -396,6 +406,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 1", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -419,6 +430,7 @@ final class ActivityTests: XCTestCase { confirmTimestamp: nil, channelId: nil, transferTxId: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil @@ -435,6 +447,7 @@ final class ActivityTests: XCTestCase { message: "Test payment 3", timestamp: timestamp, preimage: nil, + contact: nil, createdAt: nil, updatedAt: nil, seenAt: nil diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift new file mode 100644 index 000000000..06168888c --- /dev/null +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -0,0 +1,51 @@ +@testable import Bitkit +import Foundation +import XCTest + +final class PublicPaykitServiceTests: XCTestCase { + func testParseEndpointReadsSpecPayloadObject() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-bolt11", + endpointData: #"{"value":"lnbc1example","min":"1000","max":"2000"}"# + ) + + XCTAssertEqual(endpoint?.methodId, .bitcoinLightningBolt11) + XCTAssertEqual(endpoint?.value, "lnbc1example") + XCTAssertEqual(endpoint?.min, "1000") + XCTAssertEqual(endpoint?.max, "2000") + } + + func testParseEndpointRejectsRawStringPayload() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-bitcoin-p2wpkh", + endpointData: "bc1qexampleaddress" + ) + + XCTAssertNil(endpoint) + } + + func testParseEndpointRejectsUnsupportedMethodId() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-bolt12", + endpointData: #"{"value":"anything"}"# + ) + + XCTAssertNil(endpoint) + } + + func testSerializePayloadWrapsValueInJsonObject() throws { + let payload = try PublicPaykitService.serializePayload(value: " lnbc1invoice ") + let json = try XCTUnwrap(payload.data(using: .utf8)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: String]) + + XCTAssertEqual(object["value"], "lnbc1invoice") + XCTAssertEqual(object.count, 1) + } + + func testOnchainMethodIdUsesAddressPrefix() { + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample"), .bitcoinOnchainP2tr) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample"), .bitcoinOnchainP2wpkh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "3Example"), .bitcoinOnchainP2sh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "1Example"), .bitcoinOnchainP2pkh) + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 720679832..9003705ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Support publishing spec-compliant public Paykit endpoints and paying pubky contacts or scanned pubkys through their public payment endpoints - Restore pubky sessions from wallet backups and improve iOS pubky profile, contacts, and clipboard flows #527 - Pubky profile onboarding with contact sync, import, and editing #476 - Add transfer from savings button on empty spending wallet when user has on-chain balance #523 From 11b62cedf5cc267d81f71ce4ae38cfbb98f5dd2a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 27 Apr 2026 07:49:35 -0700 Subject: [PATCH 02/26] fix: align contact add flow with Android --- Bitkit/Managers/ContactsManager.swift | 29 ++++++++++---- Bitkit/Managers/ScannerManager.swift | 8 ++++ .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Views/Contacts/AddContactSheet.swift | 5 ++- Bitkit/Views/Contacts/AddContactView.swift | 38 +++++++++---------- Bitkit/Views/Contacts/ContactsIntroView.swift | 5 ++- Bitkit/Views/Contacts/ContactsListView.swift | 14 +++---- Bitkit/Views/Scanner/ScannerScreen.swift | 4 ++ BitkitTests/ContactsManagerTests.swift | 16 ++++++++ 9 files changed, 81 insertions(+), 39 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 067f6420e..e4a234649 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -49,6 +49,7 @@ enum PubkyPublicKeyFormat { enum AddContactValidationResult: Equatable { case empty + case existingContact case invalidKey case ownKey case valid(normalizedKey: String) @@ -57,6 +58,8 @@ enum AddContactValidationResult: Equatable { switch self { case .empty, .valid: nil + case .existingContact: + t("contacts__add_error_existing") case .invalidKey: t("contacts__add_error_invalid_key") case .ownKey: @@ -65,7 +68,11 @@ enum AddContactValidationResult: Equatable { } } -func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddContactValidationResult { +func resolveAddContactValidation( + input: String, + ownPublicKey: String?, + existingContacts: [PubkyContact] = [] +) -> AddContactValidationResult { let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedInput.isEmpty else { @@ -80,12 +87,17 @@ func resolveAddContactValidation(input: String, ownPublicKey: String?) -> AddCon return .invalidKey } + if existingContacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, normalizedKey) }) { + return .existingContact + } + return .valid(normalizedKey: normalizedKey) } enum ContactsManagerError: LocalizedError { case invalidPublicKey case cannotAddYourself + case alreadyExists var errorDescription: String? { switch self { @@ -93,13 +105,15 @@ enum ContactsManagerError: LocalizedError { return t("contacts__add_error_invalid_key") case .cannotAddYourself: return t("contacts__add_error_self") + case .alreadyExists: + return t("contacts__add_error_existing") } } } // MARK: - PubkyContact -struct PubkyContact: Identifiable, Hashable, Sendable { +struct PubkyContact: Identifiable, Hashable { let id: String let publicKey: String let profile: PubkyProfile @@ -142,6 +156,7 @@ class ContactsManager: ObservableObject { @Published var isLoading = false @Published var hasLoaded = false @Published var loadErrorMessage: String? + @Published var shouldOpenAddContactSheet = false /// Temporarily holds contacts discovered during import (e.g., from pubky.app after Ring auth). /// Cleared after import is completed or discarded. @@ -164,6 +179,7 @@ class ContactsManager: ObservableObject { isLoading = false hasLoaded = false loadErrorMessage = nil + shouldOpenAddContactSheet = false clearPendingImport() } @@ -290,9 +306,8 @@ class ContactsManager: ObservableObject { throw ContactsManagerError.cannotAddYourself } - guard !contacts.contains(where: { $0.publicKey == prefixedKey }) else { - Logger.debug("Contact \(prefixedKey) already exists, skipping add", context: "ContactsManager") - return + guard !contacts.contains(where: { PubkyPublicKeyFormat.matches($0.publicKey, prefixedKey) }) else { + throw ContactsManagerError.alreadyExists } // Use existing profile if provided (e.g., already fetched during preview), @@ -697,13 +712,11 @@ class ContactsManager: ObservableObject { } let normalized = message.lowercased() - let indicatesMissingResource = normalized.contains("404") + return normalized.contains("404") || normalized.contains("no such file") || normalized.contains("does not exist") || normalized.contains("profile not found") || normalized.contains("profilenotfound") || (normalized.contains("fetch failed") && normalized.contains("not found")) - - return indicatesMissingResource } } diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 67d8f14ea..f5e4ef842 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -3,6 +3,7 @@ import SwiftUI import Vision enum ScannerContext { + case addContact case main case send case electrum @@ -40,6 +41,8 @@ class ScannerManager: ObservableObject { Haptics.play(.scanSuccess) switch context { + case .addContact: + handleAddContactScan(uri) case .main: await handleMainScan(uri) case .send: @@ -49,6 +52,11 @@ class ScannerManager: ObservableObject { } } + private func handleAddContactScan(_ input: String) { + navigation?.navigateBack() + navigation?.navigate(.addContact(publicKey: input)) + } + private func handleMainScan(_ uri: String) async { guard let app else { return } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 28741329e..10a3f44a5 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -975,6 +975,7 @@ "contacts__add_pubky_placeholder" = "Paste a pubky"; "contacts__add_scan_qr" = "Scan QR"; "contacts__add_button" = "Add"; +"contacts__add_error_existing" = "This pubky is already in your contacts."; "contacts__add_error_invalid_key" = "Invalid pubky key format. Please check and try again."; "contacts__add_error_self" = "You can't add your own pubky as a contact."; "contacts__add_retrieving" = "Retrieving\ncontact info"; diff --git a/Bitkit/Views/Contacts/AddContactSheet.swift b/Bitkit/Views/Contacts/AddContactSheet.swift index e89bd8fce..b33d6992a 100644 --- a/Bitkit/Views/Contacts/AddContactSheet.swift +++ b/Bitkit/Views/Contacts/AddContactSheet.swift @@ -4,13 +4,14 @@ struct AddContactSheet: View { @Environment(\.dismiss) private var dismiss let currentPublicKey: String? + let contacts: [PubkyContact] let onAdd: (String) -> Void let onScanQR: () -> Void @State private var pubkyInput: String = "" private var validationResult: AddContactValidationResult { - resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey) + resolveAddContactValidation(input: pubkyInput, ownPublicKey: currentPublicKey, existingContacts: contacts) } private var validationMessage: String? { @@ -116,7 +117,7 @@ struct AddContactSheet: View { #Preview { Color.clear .sheet(isPresented: .constant(true)) { - AddContactSheet(currentPublicKey: nil, onAdd: { _ in }, onScanQR: {}) + AddContactSheet(currentPublicKey: nil, contacts: [], onAdd: { _ in }, onScanQR: {}) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 46d4fa979..4c718a9a2 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -16,7 +16,6 @@ struct AddContactView: View { @State private var isLoading = true @State private var isSaving = false @State private var errorMessage: String? - @State private var canRetry = true private var truncatedPublicKey: String { let displayKey = normalizedPublicKey ?? publicKey @@ -25,7 +24,11 @@ struct AddContactView: View { } private var normalizedPublicKey: String? { - if case let .valid(normalizedKey) = resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) { + if case let .valid(normalizedKey) = resolveAddContactValidation( + input: publicKey, + ownPublicKey: pubkyProfile.publicKey, + existingContacts: contactsManager.contacts + ) { return normalizedKey } @@ -57,7 +60,6 @@ struct AddContactView: View { @State private var dashedCircleRotation: Double = 0 - @ViewBuilder private var loadingContent: some View { VStack(spacing: 0) { CaptionMText(truncatedPublicKey, textColor: .white64) @@ -96,7 +98,6 @@ struct AddContactView: View { } } - @ViewBuilder private var retrievingAnimation: some View { ZStack { Image("ellipse-outer-green") @@ -120,7 +121,6 @@ struct AddContactView: View { // MARK: - Result State - @ViewBuilder private func resultContent(_ profile: PubkyProfile) -> some View { VStack(spacing: 0) { ScrollView { @@ -176,7 +176,6 @@ struct AddContactView: View { // MARK: - Error State - @ViewBuilder private var errorContent: some View { VStack(spacing: 16) { Spacer() @@ -186,17 +185,11 @@ struct AddContactView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) - if canRetry { - CustomButton(title: t("profile__retry_load"), variant: .secondary) { - await loadProfile() - } - .accessibilityIdentifier("AddContactRetry") - } else { - CustomButton(title: t("common__discard"), variant: .secondary) { - navigation.navigateBack() - } - .accessibilityIdentifier("AddContactDiscardInvalid") + CustomButton(title: t("common__retry"), variant: .secondary) { + await loadProfile() } + .accessibilityIdentifier("AddContactRetry") + Spacer() } .padding(.horizontal, 32) @@ -209,17 +202,22 @@ struct AddContactView: View { isLoading = true fetchedProfile = nil errorMessage = nil - canRetry = true - switch resolveAddContactValidation(input: publicKey, ownPublicKey: pubkyProfile.publicKey) { + switch resolveAddContactValidation( + input: publicKey, + ownPublicKey: pubkyProfile.publicKey, + existingContacts: contactsManager.contacts + ) { case .empty, .invalidKey: errorMessage = t("contacts__add_error_invalid_key") - canRetry = false isLoading = false return case .ownKey: errorMessage = t("contacts__add_error_self") - canRetry = false + isLoading = false + return + case .existingContact: + errorMessage = t("contacts__add_error_existing") isLoading = false return case let .valid(normalizedKey): diff --git a/Bitkit/Views/Contacts/ContactsIntroView.swift b/Bitkit/Views/Contacts/ContactsIntroView.swift index 31bb769a2..37b811cd7 100644 --- a/Bitkit/Views/Contacts/ContactsIntroView.swift +++ b/Bitkit/Views/Contacts/ContactsIntroView.swift @@ -4,6 +4,7 @@ struct ContactsIntroView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var contactsManager: ContactsManager var body: some View { OnboardingView( @@ -11,10 +12,11 @@ struct ContactsIntroView: View { title: t("contacts__intro_title"), description: t("contacts__intro_description"), imageName: "group", - buttonText: t("common__continue"), + buttonText: t("contacts__intro_add_contact"), onButtonPress: { app.hasSeenContactsIntro = true if pubkyProfile.isAuthenticated { + contactsManager.shouldOpenAddContactSheet = true navigation.navigate(.contacts) } else if app.hasSeenProfileIntro { navigation.navigate(.pubkyChoice) @@ -36,6 +38,7 @@ struct ContactsIntroView: View { .environmentObject(AppViewModel()) .environmentObject(NavigationViewModel()) .environmentObject(PubkyProfileManager()) + .environmentObject(ContactsManager()) .preferredColorScheme(.dark) } } diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift index 8a703f310..bd1c11e39 100644 --- a/Bitkit/Views/Contacts/ContactsListView.swift +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -53,6 +53,7 @@ struct ContactsListView: View { .sheet(isPresented: $showAddContactSheet) { AddContactSheet( currentPublicKey: pubkyProfile.publicKey, + contacts: contactsManager.contacts, onAdd: { pubky in navigation.navigate(.addContact(publicKey: pubky)) }, @@ -61,11 +62,15 @@ struct ContactsListView: View { } ) } + .onChange(of: contactsManager.shouldOpenAddContactSheet, initial: true) { _, shouldOpen in + guard shouldOpen else { return } + showAddContactSheet = true + contactsManager.shouldOpenAddContactSheet = false + } } // MARK: - Search Bar + Add Button - @ViewBuilder private var searchAndAddBar: some View { HStack(spacing: 12) { HStack(spacing: 12) { @@ -118,7 +123,6 @@ struct ContactsListView: View { // MARK: - My Profile Section - @ViewBuilder private func myProfileSection(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 0) { sectionHeader(t("contacts__my_profile")) @@ -163,7 +167,6 @@ struct ContactsListView: View { // MARK: - Section Header - @ViewBuilder private func sectionHeader(_ title: String) -> some View { CaptionMText(title, textColor: .white64) .padding(.vertical, 16) @@ -171,7 +174,6 @@ struct ContactsListView: View { // MARK: - Contact Row - @ViewBuilder private func contactRow(name: String, truncatedKey: String, imageUrl: String?, onTap: @escaping () -> Void) -> some View { Button(action: onTap) { HStack(spacing: 16) { @@ -191,7 +193,6 @@ struct ContactsListView: View { .accessibilityLabel(name) } - @ViewBuilder private func contactAvatar(name: String, imageUrl: String?) -> some View { Group { if let imageUrl { @@ -225,7 +226,6 @@ struct ContactsListView: View { // MARK: - Loading & Empty States - @ViewBuilder private var loadingContent: some View { VStack { Spacer() @@ -235,7 +235,6 @@ struct ContactsListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private func errorContent(message: String) -> some View { VStack(spacing: 16) { Spacer() @@ -260,7 +259,6 @@ struct ContactsListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private var emptyContent: some View { VStack(spacing: 0) { if pubkyProfile.isAuthenticated, let profile = pubkyProfile.profile { diff --git a/Bitkit/Views/Scanner/ScannerScreen.swift b/Bitkit/Views/Scanner/ScannerScreen.swift index c79684ac7..9e27dffd4 100644 --- a/Bitkit/Views/Scanner/ScannerScreen.swift +++ b/Bitkit/Views/Scanner/ScannerScreen.swift @@ -17,6 +17,10 @@ struct ScannerScreen: View { return .electrum } + if navigation.path.dropLast().last == .contacts { + return .addContact + } + return .main } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index e81c490e1..757ad9eab 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -45,6 +45,20 @@ final class ContactsManagerTests: XCTestCase { ) } + func testResolveAddContactValidationReturnsExistingContactForDuplicate() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let publicKey = "pubky\(rawKey)" + + XCTAssertEqual( + resolveAddContactValidation( + input: rawKey, + ownPublicKey: nil, + existingContacts: [makeContact(publicKey: publicKey)] + ), + .existingContact + ) + } + func testResolveAddContactValidationReturnsNormalizedKeyForValidInput() { let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @@ -62,6 +76,7 @@ final class ContactsManagerTests: XCTestCase { manager.contacts = [contact] manager.hasLoaded = true manager.loadErrorMessage = "still here" + manager.shouldOpenAddContactSheet = true manager.pendingImportProfile = profile manager.pendingImportContacts = [contact] @@ -70,6 +85,7 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(manager.contacts, [contact]) XCTAssertTrue(manager.hasLoaded) XCTAssertEqual(manager.loadErrorMessage, "still here") + XCTAssertTrue(manager.shouldOpenAddContactSheet) XCTAssertNil(manager.pendingImportProfile) XCTAssertTrue(manager.pendingImportContacts.isEmpty) XCTAssertFalse(manager.hasPendingImport) From c4458e4f72ca5ace89060290761a4ddf54032f6b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 29 Apr 2026 06:05:01 -0700 Subject: [PATCH 03/26] fix: complete public Paykit contact payments --- Bitkit.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 3 +- Bitkit/Constants/Env.swift | 2 +- .../Localization/en.lproj/Localizable.strings | 1 + Bitkit/Services/PublicPaykitService.swift | 132 ++++++++++++++---- Bitkit/ViewModels/AppViewModel.swift | 15 +- Bitkit/Views/Contacts/AddContactView.swift | 16 ++- Bitkit/Views/Contacts/ContactDetailView.swift | 26 ++-- Bitkit/Views/Profile/PayContactsView.swift | 1 + .../Wallets/Send/SendConfirmationView.swift | 12 +- .../Wallets/Send/SendPendingScreen.swift | 27 +++- Bitkit/Views/Wallets/Send/SendSuccess.swift | 21 ++- BitkitTests/PublicPaykitServiceTests.swift | 111 +++++++++++++++ 13 files changed, 317 insertions(+), 54 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index e4f6ee38f..de85746e2 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -904,8 +904,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { - kind = revision; - revision = cd1253291b1582759d569372d5942b8871527ea1; + kind = exactVersion; + version = 0.1.0-rc3; }; }; 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 097ad5ed6..3172920b3 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "cd1253291b1582759d569372d5942b8871527ea1" + "revision" : "e572980d75c848bd73d8e57a9c99cf5b8096d487", + "version" : "0.1.0-rc3" } }, { diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 4da84ebc4..845b69e0b 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -272,7 +272,7 @@ enum Env { case .bitcoin: return "/pub/bitkit.to/:rw,/pub/pubky.app/:r,/pub/paykit/v0/:rw" default: - return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/staging.paykit/v0/:rw" + return "/pub/staging.bitkit.to/:rw,/pub/staging.pubky.app/:r,/pub/paykit/v0/:rw" } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 10a3f44a5..82914c7b1 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1040,6 +1040,7 @@ "slashtags__error_deleting_profile" = "Unable To Delete Profile"; "slashtags__error_pay_title" = "Unable To Pay Contact"; "slashtags__error_pay_empty_msg" = "The contact you're trying to send to hasn't enabled payments."; +"slashtags__error_pay_not_opened_msg" = "No compatible payment endpoint is available."; "slashtags__auth_depricated_title" = "Deprecated"; "slashtags__auth_depricated_msg" = "Slashauth is deprecated. Please use Bitkit Beta."; "profile__nav_title" = "Profile"; diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index bcfd7b737..5dcd9ea61 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -23,12 +23,23 @@ enum PublicPaykitPaymentLaunchResult { case opened case noEndpoint case notOpened + + var contactPaymentFailureMessageKey: String? { + switch self { + case .opened: + nil + case .noEndpoint: + "slashtags__error_pay_empty_msg" + case .notOpened: + "slashtags__error_pay_not_opened_msg" + } + } } enum PublicPaykitService { enum MethodId: String, Hashable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" - case bitcoinLightningLnurl = "btc-lightning-lnurl-pay" + case bitcoinLightningLnurl = "btc-lightning-lnurl" case bitcoinOnchainP2tr = "btc-bitcoin-p2tr" case bitcoinOnchainP2wpkh = "btc-bitcoin-p2wpkh" case bitcoinOnchainP2sh = "btc-bitcoin-p2sh" @@ -50,6 +61,13 @@ enum PublicPaykitService { .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh, ] + + static let onchainPreferenceOrder: [MethodId] = [ + .bitcoinOnchainP2tr, + .bitcoinOnchainP2wpkh, + .bitcoinOnchainP2sh, + .bitcoinOnchainP2pkh, + ] } struct Endpoint: Equatable, Hashable { @@ -114,7 +132,7 @@ enum PublicPaykitService { @MainActor static func syncPublishedEndpoints(wallet: WalletViewModel, publish: Bool) async throws { guard publish else { - await removePublishedEndpoints() + try await removePublishedEndpoints() return } @@ -128,12 +146,36 @@ enum PublicPaykitService { try await applyPublishedEndpoints(desiredEndpoints) } - static func removePublishedEndpoints() async { - for methodId in MethodId.publishableMethodIds { - try? await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + static func removePublishedEndpoints() async throws { + let existingMethodIds = try await currentPublishedMethodIds() + + for methodId in MethodId.publishableMethodIds where existingMethodIds.contains(methodId) { + try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) } } + static func hasPayablePublicEndpoint(publicKey: String) async throws -> Bool { + let endpoints = try await payablePublicEndpoints(publicKey: publicKey) + return !endpoints.isEmpty + } + + static func payablePublicEndpoints(publicKey: String) async throws -> [Endpoint] { + let endpoints = try await fetchPublicEndpoints(publicKey: publicKey) + return await payableEndpoints(from: endpoints) + } + + static func payableEndpoints(from endpoints: [Endpoint]) async -> [Endpoint] { + var payableEndpoints: [Endpoint] = [] + + for endpoint in endpoints { + if await isPayableEndpoint(endpoint) { + payableEndpoints.append(endpoint) + } + } + + return payableEndpoints + } + @MainActor static func beginPayment( to publicKey: String, @@ -143,28 +185,65 @@ enum PublicPaykitService { sheets: SheetViewModel ) async throws -> PublicPaykitPaymentLaunchResult { let endpoints = try await fetchPublicEndpoints(publicKey: publicKey) + let payableEndpoints = await payableEndpoints(from: endpoints) - guard let preferredEndpoint = await preferredPayableEndpoint(from: endpoints) else { + guard !payableEndpoints.isEmpty else { return endpoints.isEmpty ? .noEndpoint : .notOpened } - try await app.handleScannedData(preferredEndpoint.paymentRequest) + try await app.handleScannedData(paymentRequest(from: payableEndpoints)) - guard PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) != nil else { + guard let route = contactPaymentRoute(app: app, currency: currency, settings: settings) else { return .notOpened } app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) - PaymentNavigationHelper.openPaymentSheet( - app: app, - currency: currency, - settings: settings, - sheetViewModel: sheets - ) + sheets.showSheet(.send, data: SendConfig(view: route)) return .opened } + static func paymentRequest(from endpoints: [Endpoint]) -> String { + guard let onchainEndpoint = MethodId.onchainPreferenceOrder.compactMap({ methodId in endpoints.first { $0.methodId == methodId } }).first, + let bolt11Endpoint = endpoints.first(where: { $0.methodId == .bitcoinLightningBolt11 }) + else { + return endpoints.first?.paymentRequest ?? "" + } + + return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(bolt11Endpoint.paymentRequest)" + } + + @MainActor + private static func contactPaymentRoute( + app: AppViewModel, + currency: CurrencyViewModel, + settings: SettingsViewModel + ) -> SendRoute? { + guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else { + return nil + } + + switch route { + case .quickpay: + if app.lnurlPayData != nil { + return .lnurlPayAmount + } + + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + + return route + case .confirm: + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + return route + default: + return route + } + } + static func onchainMethodId(for address: String) -> MethodId { let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -220,11 +299,22 @@ enum PublicPaykitService { ) } - for methodId in MethodId.publishableMethodIds where !desiredMethodIds.contains(methodId) { - try? await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + let existingMethodIds = try await currentPublishedMethodIds() + + for methodId in MethodId.publishableMethodIds where existingMethodIds.contains(methodId) && !desiredMethodIds.contains(methodId) { + try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) } } + private static func currentPublishedMethodIds() async throws -> Set { + guard let publicKey = await PubkyService.currentPublicKey() else { + throw PubkyServiceError.sessionNotActive + } + + let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) + return Set(paymentEntries.compactMap { MethodId(rawValue: $0.methodId) }) + } + @MainActor private static func buildWalletEndpoints(wallet: WalletViewModel, refreshIfNeeded: Bool) async throws -> [Endpoint] { if refreshIfNeeded { @@ -272,16 +362,6 @@ enum PublicPaykitService { return endpoints } - private static func preferredPayableEndpoint(from endpoints: [Endpoint]) async -> Endpoint? { - for endpoint in endpoints { - if await isPayableEndpoint(endpoint) { - return endpoint - } - } - - return nil - } - private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool { switch endpoint.methodId { case .bitcoinLightningBolt11: diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index bbfb8ab01..a38548559 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -67,6 +67,7 @@ class AppViewModel: ObservableObject { /// Payment hashes for which we navigated to the pending screen. /// When payment succeeds/fails, we show toast and publish resolution so SendPendingScreen can navigate. private var pendingPaymentHashes: Set = [] + private var pendingContactPaymentContexts: [String: ContactPaymentContext] = [:] /// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate. /// Consumed by SendPendingScreen via consumeSendSheetPendingResolution. @@ -293,8 +294,20 @@ extension AppViewModel { // MARK: Pending payment tracking extension AppViewModel { - func addPendingPaymentHash(_ hash: String) { + func addPendingPaymentHash(_ hash: String, contactPublicKey: String? = nil) { pendingPaymentHashes.insert(hash) + + if let contactPublicKey { + pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey) + } + } + + func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? { + pendingContactPaymentContexts[hash] + } + + func consumeContactPaymentContext(forPendingPaymentHash hash: String) { + pendingContactPaymentContexts.removeValue(forKey: hash) } /// Called by SendPendingScreen when it consumes a resolution. Clears the published value. diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 4c718a9a2..91175bad5 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -40,7 +40,7 @@ struct AddContactView: View { NavigationBar(title: t("contacts__add_title")) .padding(.horizontal, 16) - if isLoading && fetchedProfile == nil { + if isLoading { loadingContent } else if let profile = fetchedProfile { resultContent(profile) @@ -259,8 +259,7 @@ struct AddContactView: View { private func loadPaymentEndpoints(publicKey: String) async { do { - let endpoints = try await PublicPaykitService.fetchPublicEndpoints(publicKey: publicKey) - hasPublicPaymentEndpoint = !endpoints.isEmpty + hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) } catch { Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "AddContactView") hasPublicPaymentEndpoint = false @@ -282,12 +281,21 @@ struct AddContactView: View { sheets: sheets ) - if case .noEndpoint = result { + switch result { + case .opened: + break + case .noEndpoint: app.toast( type: .warning, title: t("slashtags__error_pay_title"), description: t("slashtags__error_pay_empty_msg") ) + case .notOpened: + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) } } catch { Logger.error("Failed to pay public pubky \(publicKey): \(error)", context: "AddContactView") diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index d689b6971..3f0411c74 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -21,7 +21,7 @@ struct ContactDetailView: View { NavigationBar(title: t("contacts__detail_title")) .padding(.horizontal, 16) - if isLoading && profile == nil { + if isLoading { loadingContent } else if let profile { contactBody(profile) @@ -54,7 +54,6 @@ struct ContactDetailView: View { // MARK: - Contact Body - @ViewBuilder private func contactBody(_ profile: PubkyProfile) -> some View { ScrollView { VStack(spacing: 0) { @@ -93,7 +92,6 @@ struct ContactDetailView: View { // MARK: - Action Buttons - @ViewBuilder private var contactActions: some View { HStack(spacing: 16) { if hasPublicPaymentEndpoint { @@ -130,7 +128,6 @@ struct ContactDetailView: View { // MARK: - Links / Metadata - @ViewBuilder private func linksSection(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(profile.links.enumerated()), id: \.element.id) { index, link in @@ -141,7 +138,6 @@ struct ContactDetailView: View { // MARK: - Tags - @ViewBuilder private func tagsSection(_ profile: PubkyProfile) -> some View { VStack(alignment: .leading, spacing: 8) { CaptionMText(t("profile__create_tags_label"), textColor: .white64) @@ -159,7 +155,6 @@ struct ContactDetailView: View { } } - @ViewBuilder private var addTagButton: some View { IconActionButton( icon: "tag", @@ -222,7 +217,6 @@ struct ContactDetailView: View { // MARK: - Loading & Empty States - @ViewBuilder private var loadingContent: some View { VStack { Spacer() @@ -232,12 +226,14 @@ struct ContactDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private var emptyContent: some View { VStack(spacing: 16) { Spacer() BodyMText(t("contacts__detail_empty_state")) CustomButton(title: t("profile__retry_load"), variant: .secondary) { + isLoading = true + defer { isLoading = false } + if let contact = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) { profile = contact.profile } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) { @@ -274,8 +270,7 @@ struct ContactDetailView: View { private func loadPaymentEndpoints() async { do { - let endpoints = try await PublicPaykitService.fetchPublicEndpoints(publicKey: publicKey) - hasPublicPaymentEndpoint = !endpoints.isEmpty + hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) } catch { Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "ContactDetailView") hasPublicPaymentEndpoint = false @@ -292,12 +287,21 @@ struct ContactDetailView: View { sheets: sheets ) - if case .noEndpoint = result { + switch result { + case .opened: + break + case .noEndpoint: app.toast( type: .warning, title: t("slashtags__error_pay_title"), description: t("slashtags__error_pay_empty_msg") ) + case .notOpened: + app.toast( + type: .warning, + title: t("slashtags__error_pay_title"), + description: t("slashtags__error_pay_not_opened_msg") + ) } } catch { Logger.error("Failed to pay contact \(publicKey): \(error)", context: "ContactDetailView") diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index d239ed762..b1bbb660a 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -74,6 +74,7 @@ struct PayContactsView: View { hasConfirmedPublicPaykitEndpoints = true navigation.path = [.profile] } catch { + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView") app.toast( type: .error, diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index abe1d9247..ace72cf85 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -447,6 +447,7 @@ struct SendConfirmationView: View { private func performPayment() async throws { var createdMetadataPaymentId: String? = nil + let contactPublicKey = app.contactPaymentContext?.publicKey do { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { @@ -468,11 +469,11 @@ struct SendConfirmationView: View { bolt11: invoice.bolt11, sats: paymentSats, onTimeout: { - app.addPendingPaymentHash(paymentHash) + app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) navigationPath.append(.pending(paymentHash: paymentHash)) } ) - await syncContactForActivity(paymentId: paymentHash) + await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { @@ -497,7 +498,7 @@ struct SendConfirmationView: View { amount: amount, fee: UInt64(transactionFee), feeRate: wallet.selectedFeeRateSatsPerVByte ?? 1, - contact: app.contactPaymentContext?.publicKey + contact: contactPublicKey ) // Set the amount for the success screen @@ -528,8 +529,8 @@ struct SendConfirmationView: View { } } - private func syncContactForActivity(paymentId: String) async { - guard let contactPublicKey = app.contactPaymentContext?.publicKey else { + private func syncContactForActivity(paymentId: String, contactPublicKey: String?) async { + guard let contactPublicKey else { return } @@ -539,6 +540,7 @@ struct SendConfirmationView: View { do { try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentId) + app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { Logger.warn("Failed to set contact for activity \(paymentId): \(error)", context: "SendConfirmationView") } diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index 5340d2a49..c212a84bb 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -81,8 +81,12 @@ struct SendPendingScreen: View { guard let resolution, resolution.paymentHash == paymentHash else { return } app.consumeSendSheetPendingResolution(paymentHash: paymentHash) if resolution.success { - navigationPath.append(.success(paymentId: paymentHash)) + Task { @MainActor in + await applyPendingContactContextIfNeeded() + navigationPath.append(.success(paymentId: paymentHash)) + } } else { + app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) navigationPath.append(.failure) } } @@ -97,9 +101,28 @@ struct SendPendingScreen: View { times: 12, interval: 2 ) - foundActivity = activity + await applyPendingContactContextIfNeeded() + let updatedActivity = try? await activityList.findActivity(byPaymentId: paymentHash) + foundActivity = updatedActivity ?? activity } catch { Logger.warn("Could not find activity for pending payment \(paymentHash): \(error)") } } + + private func applyPendingContactContextIfNeeded() async { + guard let contactPublicKey = app.contactPaymentContext(forPendingPaymentHash: paymentHash)?.publicKey else { + return + } + + if let payments = LightningService.shared.payments { + try? await CoreService.shared.activity.syncLdkNodePayments(payments) + } + + do { + try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentHash) + app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) + } catch { + Logger.warn("Failed to set pending contact for payment \(paymentHash): \(error)", context: "SendPendingScreen") + } + } } diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index cc430dc6d..9386e6c71 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -111,9 +111,28 @@ struct SendSuccess: View { interval: 5 ) - foundActivity = activity + await applyPendingContactContextIfNeeded() + let updatedActivity = try? await activityListViewModel.findActivity(byPaymentId: paymentId) + foundActivity = updatedActivity ?? activity } catch { Logger.warn("Could not find activity for payment ID: \(paymentId) after 12 attempts") } } + + private func applyPendingContactContextIfNeeded() async { + guard let contactPublicKey = app.contactPaymentContext(forPendingPaymentHash: paymentId)?.publicKey else { + return + } + + if let payments = LightningService.shared.payments { + try? await CoreService.shared.activity.syncLdkNodePayments(payments) + } + + do { + try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentId) + app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) + } catch { + Logger.warn("Failed to set pending contact for payment \(paymentId): \(error)", context: "SendSuccess") + } + } } diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 06168888c..5e6c63462 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -33,6 +33,41 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertNil(endpoint) } + func testParseEndpointReadsPaykyLnurlMethodId() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-lnurl", + endpointData: #"{"value":"lnurl1example"}"# + ) + + XCTAssertEqual(endpoint?.methodId, .bitcoinLightningLnurl) + XCTAssertEqual(endpoint?.value, "lnurl1example") + } + + func testParseEndpointRejectsNonSpecLegacyLnurlMethodId() { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: "btc-lightning-lnurl-pay", + endpointData: #"{"value":"lnurl1example"}"# + ) + + XCTAssertNil(endpoint) + } + + func testKnownMethodIdsFollowPaymentEndpointIdentifierSpec() { + let specPattern = #"^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$"# + let methodIds: [PublicPaykitService.MethodId] = [ + .bitcoinLightningBolt11, + .bitcoinLightningLnurl, + .bitcoinOnchainP2tr, + .bitcoinOnchainP2wpkh, + .bitcoinOnchainP2sh, + .bitcoinOnchainP2pkh, + ] + + for methodId in methodIds { + XCTAssertNotNil(methodId.rawValue.range(of: specPattern, options: .regularExpression), "\(methodId.rawValue) must be asset-rail-endpoint") + } + } + func testSerializePayloadWrapsValueInJsonObject() throws { let payload = try PublicPaykitService.serializePayload(value: " lnbc1invoice ") let json = try XCTUnwrap(payload.data(using: .utf8)) @@ -42,10 +77,86 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(object.count, 1) } + func testPaymentRequestCombinesOnchainAndBolt11Endpoints() { + let request = PublicPaykitService.paymentRequest(from: [ + PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: "lnbc1invoice", + min: nil, + max: nil, + rawPayload: #"{"value":"lnbc1invoice"}"# + ), + PublicPaykitService.Endpoint( + methodId: .bitcoinOnchainP2wpkh, + value: "bc1qaddress", + min: nil, + max: nil, + rawPayload: #"{"value":"bc1qaddress"}"# + ), + ]) + + XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice") + } + + func testPaymentRequestPrefersTaprootWhenMultipleOnchainEndpointsExist() { + let request = PublicPaykitService.paymentRequest(from: [ + PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: "lnbc1invoice", + min: nil, + max: nil, + rawPayload: #"{"value":"lnbc1invoice"}"# + ), + PublicPaykitService.Endpoint( + methodId: .bitcoinOnchainP2pkh, + value: "1legacy", + min: nil, + max: nil, + rawPayload: #"{"value":"1legacy"}"# + ), + PublicPaykitService.Endpoint( + methodId: .bitcoinOnchainP2wpkh, + value: "bc1qsegwit", + min: nil, + max: nil, + rawPayload: #"{"value":"bc1qsegwit"}"# + ), + PublicPaykitService.Endpoint( + methodId: .bitcoinOnchainP2tr, + value: "bc1ptaproot", + min: nil, + max: nil, + rawPayload: #"{"value":"bc1ptaproot"}"# + ), + ]) + + XCTAssertEqual(request, "bitcoin:bc1ptaproot?lightning=lnbc1invoice") + } + + func testPaymentRequestFallsBackToPreferredEndpointWhenCombinedRequestIsNotAvailable() { + let request = PublicPaykitService.paymentRequest(from: [ + PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: "lnbc1invoice", + min: nil, + max: nil, + rawPayload: #"{"value":"lnbc1invoice"}"# + ), + ]) + + XCTAssertEqual(request, "lnbc1invoice") + } + func testOnchainMethodIdUsesAddressPrefix() { XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample"), .bitcoinOnchainP2tr) XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample"), .bitcoinOnchainP2wpkh) XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "3Example"), .bitcoinOnchainP2sh) XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "1Example"), .bitcoinOnchainP2pkh) } + + func testPaymentLaunchResultFailureMessageKeys() { + XCTAssertNil(PublicPaykitPaymentLaunchResult.opened.contactPaymentFailureMessageKey) + XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.contactPaymentFailureMessageKey, "slashtags__error_pay_empty_msg") + XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.contactPaymentFailureMessageKey, "slashtags__error_pay_not_opened_msg") + } } From c863f46ba96459e93d6371fc458f4b51bd4ef328 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 29 Apr 2026 07:25:40 -0700 Subject: [PATCH 04/26] Update CHANGELOG.md Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9003705ad..9e2b4c540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Support publishing spec-compliant public Paykit endpoints and paying pubky contacts or scanned pubkys through their public payment endpoints +- Support publishing spec-compliant public Paykit endpoints and paying pubky contacts or scanned pubkys through their public payment endpoints #531 - Restore pubky sessions from wallet backups and improve iOS pubky profile, contacts, and clipboard flows #527 - Pubky profile onboarding with contact sync, import, and editing #476 - Add transfer from savings button on empty spending wallet when user has on-chain balance #523 From 16b34dc9c77afdf9f20b5fe1a18673fd73afef5b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 29 Apr 2026 07:27:37 -0700 Subject: [PATCH 05/26] fix: surface contact activity load errors --- .../Views/Contacts/ContactActivityView.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index 6d99067b5..cf799ff24 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -2,6 +2,7 @@ import BitkitCore import SwiftUI struct ContactActivityView: View { + @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var activityList: ActivityListViewModel @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager @@ -10,6 +11,7 @@ struct ContactActivityView: View { @State private var activities: [Activity] = [] @State private var isLoading = true + @State private var hasError = false @State private var contactName = "" private var groupedActivities: [ActivityGroupItem] { @@ -23,6 +25,8 @@ struct ContactActivityView: View { if isLoading { loadingContent + } else if hasError { + errorContent } else if groupedActivities.isEmpty { emptyContent } else { @@ -72,7 +76,6 @@ struct ContactActivityView: View { } } - @ViewBuilder private var loadingContent: some View { VStack { Spacer() @@ -82,7 +85,6 @@ struct ContactActivityView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - @ViewBuilder private var emptyContent: some View { VStack(spacing: 16) { Spacer() @@ -93,6 +95,16 @@ struct ContactActivityView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private var errorContent: some View { + VStack(spacing: 16) { + Spacer() + BodyMText(t("contacts__error_loading")) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private var contactDisplayName: String { if !contactName.isEmpty { return contactName @@ -127,9 +139,12 @@ struct ContactActivityView: View { do { activities = try await CoreService.shared.activity.get(contact: publicKey, sortDirection: .desc) + hasError = false } catch { Logger.error(error, context: "ContactActivityView") activities = [] + hasError = true + app.toast(type: .error, title: t("contacts__error_loading"), description: error.localizedDescription) } } } From 85858ee9d748194f98511d10ec9ee397da59140e Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 29 Apr 2026 07:44:02 -0700 Subject: [PATCH 06/26] fix: move contact activity sync out of send views --- Bitkit/ViewModels/ActivityListViewModel.swift | 9 +++++++++ Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 7 ++----- Bitkit/Views/Wallets/Send/SendPendingScreen.swift | 6 +----- Bitkit/Views/Wallets/Send/SendSuccess.swift | 6 +----- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index de18a4273..88fa804f2 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -268,6 +268,15 @@ class ActivityListViewModel: ObservableObject { return activity } + func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { + if syncLdkPayments { + try? await syncLdkNodePayments() + } + + try await coreService.activity.setContact(contactPublicKey, forActivity: paymentId) + await syncState() + } + func getAllPossibleTags() async throws -> [String] { try await coreService.activity.allPossibleTags() } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index ace72cf85..37d7ae22f 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SendConfirmationView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var activityList: ActivityListViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject var settings: SettingsViewModel @@ -534,12 +535,8 @@ struct SendConfirmationView: View { return } - if let payments = LightningService.shared.payments { - try? await CoreService.shared.activity.syncLdkNodePayments(payments) - } - do { - try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentId) + try await activityList.setContact(contactPublicKey, forPaymentId: paymentId) app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { Logger.warn("Failed to set contact for activity \(paymentId): \(error)", context: "SendConfirmationView") diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift index c212a84bb..481b7191b 100644 --- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift +++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift @@ -114,12 +114,8 @@ struct SendPendingScreen: View { return } - if let payments = LightningService.shared.payments { - try? await CoreService.shared.activity.syncLdkNodePayments(payments) - } - do { - try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentHash) + try await activityList.setContact(contactPublicKey, forPaymentId: paymentHash) app.consumeContactPaymentContext(forPendingPaymentHash: paymentHash) } catch { Logger.warn("Failed to set pending contact for payment \(paymentHash): \(error)", context: "SendPendingScreen") diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index 9386e6c71..517a1125b 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -124,12 +124,8 @@ struct SendSuccess: View { return } - if let payments = LightningService.shared.payments { - try? await CoreService.shared.activity.syncLdkNodePayments(payments) - } - do { - try await CoreService.shared.activity.setContact(contactPublicKey, forActivity: paymentId) + try await activityListViewModel.setContact(contactPublicKey, forPaymentId: paymentId) app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { Logger.warn("Failed to set pending contact for payment \(paymentId): \(error)", context: "SendSuccess") From 220ee6a8019362e917bb96c646598961327fd81b Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 29 Apr 2026 08:10:49 -0700 Subject: [PATCH 07/26] fix: keep contact payment boundaries in view layer --- Bitkit/Services/PublicPaykitService.swift | 51 +-------------- Bitkit/ViewModels/ActivityListViewModel.swift | 8 +++ Bitkit/Views/Contacts/AddContactView.swift | 64 ++++++++++++++++--- .../Views/Contacts/ContactActivityView.swift | 4 +- Bitkit/Views/Contacts/ContactDetailView.swift | 64 ++++++++++++++++--- BitkitTests/PublicPaykitServiceTests.swift | 2 +- 6 files changed, 124 insertions(+), 69 deletions(-) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 5dcd9ea61..e39801743 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -20,7 +20,7 @@ enum PublicPaykitError: LocalizedError { } enum PublicPaykitPaymentLaunchResult { - case opened + case opened(paymentRequest: String) case noEndpoint case notOpened @@ -176,13 +176,8 @@ enum PublicPaykitService { return payableEndpoints } - @MainActor static func beginPayment( - to publicKey: String, - app: AppViewModel, - currency: CurrencyViewModel, - settings: SettingsViewModel, - sheets: SheetViewModel + to publicKey: String ) async throws -> PublicPaykitPaymentLaunchResult { let endpoints = try await fetchPublicEndpoints(publicKey: publicKey) let payableEndpoints = await payableEndpoints(from: endpoints) @@ -191,16 +186,7 @@ enum PublicPaykitService { return endpoints.isEmpty ? .noEndpoint : .notOpened } - try await app.handleScannedData(paymentRequest(from: payableEndpoints)) - - guard let route = contactPaymentRoute(app: app, currency: currency, settings: settings) else { - return .notOpened - } - - app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) - sheets.showSheet(.send, data: SendConfig(view: route)) - - return .opened + return .opened(paymentRequest: paymentRequest(from: payableEndpoints)) } static func paymentRequest(from endpoints: [Endpoint]) -> String { @@ -213,37 +199,6 @@ enum PublicPaykitService { return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(bolt11Endpoint.paymentRequest)" } - @MainActor - private static func contactPaymentRoute( - app: AppViewModel, - currency: CurrencyViewModel, - settings: SettingsViewModel - ) -> SendRoute? { - guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else { - return nil - } - - switch route { - case .quickpay: - if app.lnurlPayData != nil { - return .lnurlPayAmount - } - - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount - } - - return route - case .confirm: - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount - } - return route - default: - return route - } - } - static func onchainMethodId(for address: String) -> MethodId { let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 88fa804f2..a0668f06d 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -47,6 +47,10 @@ class ActivityListViewModel: ObservableObject { @Published private(set) var availableTags: [String] = [] + var activitiesChangedPublisher: AnyPublisher { + coreService.activity.activitiesChangedPublisher + } + private func updateAvailableTags() async { do { availableTags = try await coreService.activity.allPossibleTags() @@ -268,6 +272,10 @@ class ActivityListViewModel: ObservableObject { return activity } + func contactActivities(publicKey: String) async throws -> [Activity] { + try await coreService.activity.get(contact: publicKey, sortDirection: .desc) + } + func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { if syncLdkPayments { try? await syncLdkNodePayments() diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 91175bad5..e235fab12 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -273,17 +273,18 @@ struct AddContactView: View { } do { - let result = try await PublicPaykitService.beginPayment( - to: normalizedPublicKey, - app: app, - currency: currency, - settings: settings, - sheets: sheets - ) + let result = try await PublicPaykitService.beginPayment(to: normalizedPublicKey) switch result { - case .opened: - break + 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 + } case .noEndpoint: app.toast( type: .warning, @@ -306,6 +307,51 @@ struct AddContactView: View { ) } } + + @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: "AddContactView") + return false + } + + guard let route = contactPaymentRoute() else { + return false + } + + app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + sheets.showSheet(.send, data: SendConfig(view: route)) + return true + } + + @MainActor + private func contactPaymentRoute() -> SendRoute? { + guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else { + return nil + } + + switch route { + case .quickpay: + if app.lnurlPayData != nil { + return .lnurlPayAmount + } + + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + + return route + case .confirm: + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + return route + default: + return route + } + } } #Preview { diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index cf799ff24..20df39aa4 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -40,7 +40,7 @@ struct ContactActivityView: View { resolveContactName() await loadActivities() } - .onReceive(CoreService.shared.activity.activitiesChangedPublisher) { _ in + .onReceive(activityList.activitiesChangedPublisher) { _ in Task { await loadActivities() } @@ -138,7 +138,7 @@ struct ContactActivityView: View { defer { isLoading = false } do { - activities = try await CoreService.shared.activity.get(contact: publicKey, sortDirection: .desc) + activities = try await activityList.contactActivities(publicKey: publicKey) hasError = false } catch { Logger.error(error, context: "ContactActivityView") diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 3f0411c74..42d5c09f5 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -279,17 +279,18 @@ struct ContactDetailView: View { private func payContact() async { do { - let result = try await PublicPaykitService.beginPayment( - to: publicKey, - app: app, - currency: currency, - settings: settings, - sheets: sheets - ) + let result = try await PublicPaykitService.beginPayment(to: publicKey) switch result { - case .opened: - break + 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 + } case .noEndpoint: app.toast( type: .warning, @@ -312,6 +313,51 @@ struct ContactDetailView: View { ) } } + + @MainActor + private func openContactPayment(paymentRequest: String) async -> Bool { + do { + try await app.handleScannedData(paymentRequest) + } catch { + Logger.warn("Failed to decode contact payment request: \(error)", context: "ContactDetailView") + return false + } + + guard let route = contactPaymentRoute() else { + return false + } + + app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) + sheets.showSheet(.send, data: SendConfig(view: route)) + return true + } + + @MainActor + private func contactPaymentRoute() -> SendRoute? { + guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else { + return nil + } + + switch route { + case .quickpay: + if app.lnurlPayData != nil { + return .lnurlPayAmount + } + + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + + return route + case .confirm: + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + return route + default: + return route + } + } } #Preview { diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 5e6c63462..2682241b0 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -155,7 +155,7 @@ final class PublicPaykitServiceTests: XCTestCase { } func testPaymentLaunchResultFailureMessageKeys() { - XCTAssertNil(PublicPaykitPaymentLaunchResult.opened.contactPaymentFailureMessageKey) + XCTAssertNil(PublicPaykitPaymentLaunchResult.opened(paymentRequest: "bitcoin:bcrt1ptest").contactPaymentFailureMessageKey) XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.contactPaymentFailureMessageKey, "slashtags__error_pay_empty_msg") XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.contactPaymentFailureMessageKey, "slashtags__error_pay_not_opened_msg") } From 3d1f2b24d3f240d758725dc6d793c5a6148cc8bf Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 10:49:50 -0500 Subject: [PATCH 08/26] fix: show contact names in activity rows --- Bitkit/Components/Activity/ActivityList.swift | 7 ++- Bitkit/Extensions/Activity+Contact.swift | 18 ++++++ .../Views/Contacts/ContactActivityView.swift | 2 +- .../Wallets/Activity/ActivityLatest.swift | 7 ++- .../Views/Wallets/Activity/ActivityRow.swift | 45 ++++++++++++++- BitkitTests/ContactsManagerTests.swift | 56 +++++++++++++++++++ 6 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 Bitkit/Extensions/Activity+Contact.swift diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift index a3a570fb9..33814d077 100644 --- a/Bitkit/Components/Activity/ActivityList.swift +++ b/Bitkit/Components/Activity/ActivityList.swift @@ -3,6 +3,7 @@ import SwiftUI struct ActivityList: View { @EnvironmentObject var activity: ActivityListViewModel + @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager @State private var isHorizontalSwipe = false @@ -28,7 +29,11 @@ struct ActivityList: View { case let .activity(item): NavigationLink(value: Route.activityDetail(item)) { - ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates) + ActivityRow( + item: item, + feeEstimates: feeEstimatesManager.estimates, + contact: item.contact(in: contactsManager.contacts) + ) } .accessibilityIdentifier("Activity-\(index)") .disabled(isHorizontalSwipe) diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift new file mode 100644 index 000000000..a2b9cb803 --- /dev/null +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -0,0 +1,18 @@ +import BitkitCore + +extension Activity { + func contact(in contacts: [PubkyContact]) -> PubkyContact? { + guard let contactPublicKey else { return nil } + return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) }) + } + + private var contactPublicKey: String? { + switch self { + case let .lightning(lightning): + return lightning.contact + + case let .onchain(onchain): + return onchain.contact + } + } +} diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index 20df39aa4..1c4e045be 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -114,7 +114,7 @@ struct ContactActivityView: View { } private func resolveContactName() { - contactName = contactsManager.contacts.first(where: { $0.publicKey == publicKey })?.profile.name ?? "" + contactName = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })?.displayName ?? "" } private func activityTitle(_ activity: Activity) -> String { diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 09590f633..3c53cf0e1 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -4,6 +4,7 @@ import SwiftUI struct ActivityLatest: View { @EnvironmentObject private var activity: ActivityListViewModel @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var contactsManager: ContactsManager @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var settings: SettingsViewModel @@ -60,7 +61,11 @@ struct ActivityLatest: View { LazyVStack(alignment: .leading, spacing: 16) { ForEach(Array(zip(rows.indices, rows)), id: \.1) { index, item in NavigationLink(value: Route.activityDetail(item)) { - ActivityRow(item: item, feeEstimates: feeEstimatesManager.estimates) + ActivityRow( + item: item, + feeEstimates: feeEstimatesManager.estimates, + contact: item.contact(in: contactsManager.contacts) + ) } .accessibilityIdentifier("ActivityShort-\(index)") } diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift index 7d40630fb..5cd91bcc4 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift @@ -4,21 +4,60 @@ import SwiftUI struct ActivityRow: View { let item: Activity let feeEstimates: FeeRates? + let contact: PubkyContact? let titleOverride: String? - init(item: Activity, feeEstimates: FeeRates?, titleOverride: String? = nil) { + init(item: Activity, feeEstimates: FeeRates?, contact: PubkyContact? = nil, titleOverride: String? = nil) { self.item = item self.feeEstimates = feeEstimates + self.contact = contact self.titleOverride = titleOverride } + private var rowTitleOverride: String? { + if let titleOverride { + return titleOverride + } + + return contactTitle + } + + private var contactTitle: String? { + guard let contact else { return nil } + + let txType: PaymentType + switch item { + case let .lightning(lightning): + guard lightning.status == .succeeded else { + return nil + } + txType = lightning.txType + + case let .onchain(onchain): + guard onchain.doesExist, + !onchain.isTransfer, + !(onchain.isBoosted && !onchain.confirmed) + else { + return nil + } + txType = onchain.txType + } + + switch txType { + case .sent: + return t("contacts__activity_sent_to", variables: ["name": contact.displayName]) + case .received: + return t("contacts__activity_received_from", variables: ["name": contact.displayName]) + } + } + var body: some View { Group { switch item { case let .lightning(activity): - ActivityRowLightning(item: activity, titleOverride: titleOverride) + ActivityRowLightning(item: activity, titleOverride: rowTitleOverride) case let .onchain(activity): - ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: titleOverride) + ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, titleOverride: rowTitleOverride) } } .padding(16) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index 757ad9eab..c424d8691 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -1,4 +1,5 @@ @testable import Bitkit +import BitkitCore import XCTest @MainActor @@ -24,6 +25,61 @@ final class ContactsManagerTests: XCTestCase { XCTAssertFalse(PubkyPublicKeyFormat.matches(prefixedKey, "pubkyinvalid")) } + func testActivityContactResolvesLightningContactKey() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let contact = makeContact(publicKey: "pubky\(rawKey)") + let activity = Activity.lightning( + LightningActivity( + id: "test-lightning-contact", + txType: .sent, + status: .succeeded, + value: 1000, + fee: 10, + invoice: "lnbc...", + message: "", + timestamp: 0, + preimage: nil, + contact: rawKey, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + + XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey) + } + + func testActivityContactResolvesBoostingOnchainContactKey() { + let rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let contact = makeContact(publicKey: "pubky\(rawKey)") + let activity = Activity.onchain( + OnchainActivity( + id: "test-onchain-boosting-contact", + txType: .sent, + txId: "txid", + value: 1000, + fee: 10, + feeRate: 1, + address: "bcrt1...", + confirmed: false, + timestamp: 0, + isBoosted: true, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + contact: contact.publicKey, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + + XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey) + } + func testResolveAddContactValidationReturnsEmptyForBlankInput() { XCTAssertEqual(resolveAddContactValidation(input: " ", ownPublicKey: nil), .empty) } From edc5e9fbc14ed06a1db12dd698294aa9642d75ab Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 11:12:44 -0500 Subject: [PATCH 09/26] fix: disambiguate contact test models --- BitkitTests/ContactsManagerTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index c424d8691..a67909ad3 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -273,8 +273,8 @@ final class ContactsManagerTests: XCTestCase { ) } - private func makeProfile(publicKey: String) -> PubkyProfile { - PubkyProfile( + private func makeProfile(publicKey: String) -> Bitkit.PubkyProfile { + Bitkit.PubkyProfile( publicKey: publicKey, name: "Alice", bio: "bio", @@ -285,7 +285,7 @@ final class ContactsManagerTests: XCTestCase { ) } - private func makeContact(publicKey: String) -> PubkyContact { - PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey)) + private func makeContact(publicKey: String) -> Bitkit.PubkyContact { + Bitkit.PubkyContact(publicKey: publicKey, profile: makeProfile(publicKey: publicKey)) } } From 01aefddcb4e939c91a2f22add8260511a3897103 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 11:41:18 -0500 Subject: [PATCH 10/26] fix: address public payments review cleanup --- Bitkit/Components/ContactAvatarLetter.swift | 42 +++++++ Bitkit/Managers/ContactsManager.swift | 40 ++++--- Bitkit/Models/ActivityDisplayConstants.swift | 3 + Bitkit/Services/CoreService.swift | 29 +---- Bitkit/Services/PublicPaykitService.swift | 34 ++++-- Bitkit/ViewModels/ActivityListViewModel.swift | 2 +- Bitkit/Views/Contacts/AddContactView.swift | 17 +-- .../Views/Contacts/ContactActivityView.swift | 24 ++-- .../Contacts/ContactImportOverviewView.swift | 29 +---- .../Contacts/ContactImportSelectView.swift | 17 +-- Bitkit/Views/Contacts/ContactsListView.swift | 9 +- .../Wallets/Activity/ActivityLatest.swift | 2 +- Bitkit/Views/Wallets/Send/SendSuccess.swift | 4 +- BitkitTests/PublicPaykitServiceTests.swift | 109 ++++++++++-------- CHANGELOG.md | 2 - changelog.d/next/525.fixed.md | 1 + changelog.d/next/531.added.md | 1 + 17 files changed, 198 insertions(+), 167 deletions(-) create mode 100644 Bitkit/Components/ContactAvatarLetter.swift create mode 100644 Bitkit/Models/ActivityDisplayConstants.swift create mode 100644 changelog.d/next/525.fixed.md create mode 100644 changelog.d/next/531.added.md diff --git a/Bitkit/Components/ContactAvatarLetter.swift b/Bitkit/Components/ContactAvatarLetter.swift new file mode 100644 index 000000000..d29430a9c --- /dev/null +++ b/Bitkit/Components/ContactAvatarLetter.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ContactAvatarLetter: View { + let source: String + let size: CGFloat + var backgroundColor: Color = .white.opacity(0.1) + var strokeColor: Color? + var strokeWidth: CGFloat = 0 + + private var letter: String { + String(source.prefix(1)).uppercased() + } + + var body: some View { + Circle() + .fill(backgroundColor) + .frame(width: size, height: size) + .overlay { + avatarText + } + .overlay { + if let strokeColor, strokeWidth > 0 { + Circle() + .stroke(strokeColor, lineWidth: strokeWidth) + } + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var avatarText: some View { + if size >= 72 { + HeadlineText(letter) + } else if size >= 56 { + TitleText(letter) + } else if size >= 44 { + BodyMSBText(letter) + } else { + CaptionBText(letter, textColor: .textPrimary) + } + } +} diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index e4a234649..249534995 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -45,6 +45,10 @@ enum PubkyPublicKeyFormat { return lhs == rhs } + + static func redacted(_ input: String) -> String { + (normalized(input) ?? input.trimmingCharacters(in: .whitespacesAndNewlines)).ellipsis(maxLength: 12, style: .end) + } } enum AddContactValidationResult: Equatable { @@ -201,7 +205,7 @@ class ContactsManager: ObservableObject { defer { isLoading = false } let basePath = contactsBasePath - Logger.info("Loading contacts from \(basePath) for \(publicKey)", context: "ContactsManager") + Logger.info("Loading contacts from \(basePath) for \(PubkyPublicKeyFormat.redacted(publicKey))", context: "ContactsManager") do { let sessionSecret = try getSessionSecret() @@ -230,7 +234,10 @@ class ContactsManager: ObservableObject { let profile = profileData.toProfile(publicKey: prefixedKey) return .success(PubkyContact(publicKey: prefixedKey, profile: profile)) } catch { - Logger.warn("Failed to load contact data for '\(prefixedKey)': \(error)", context: "ContactsManager") + Logger.warn( + "Failed to load contact data for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", + context: "ContactsManager" + ) return .failure(error) } } @@ -330,7 +337,7 @@ class ContactsManager: ObservableObject { let contactData = PubkyProfileData.from(profile: profile) try await savePubkyProfileData(publicKey: prefixedKey, data: contactData) - Logger.info("Added contact \(prefixedKey)", context: "ContactsManager") + Logger.info("Added contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") let contact = PubkyContact(publicKey: prefixedKey, profile: profile) contacts.append(contact) @@ -353,7 +360,7 @@ class ContactsManager: ObservableObject { try await savePubkyProfileData(publicKey: key, data: contactData) return .success(PubkyContact(publicKey: key, profile: profile)) } catch { - Logger.warn("Failed to save imported contact '\(key)': \(error)", context: "ContactsManager") + Logger.warn("Failed to save imported contact '\(PubkyPublicKeyFormat.redacted(key))': \(error)", context: "ContactsManager") return .failure(error) } } @@ -415,7 +422,7 @@ class ContactsManager: ObservableObject { contacts.sort { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } - Logger.info("Updated contact \(prefixedKey)", context: "ContactsManager") + Logger.info("Updated contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") } // MARK: - Delete Contact @@ -433,7 +440,7 @@ class ContactsManager: ObservableObject { ) }.value - Logger.info("Removed contact \(prefixedKey)", context: "ContactsManager") + Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") contacts.removeAll { $0.publicKey == prefixedKey } } @@ -474,18 +481,18 @@ class ContactsManager: ObservableObject { deletedKeys.insert(ensurePubkyPrefix(contactKey)) } catch { firstError = firstError ?? error - Logger.warn("Failed to delete contact '\(contactKey)': \(error)", context: "ContactsManager") + Logger.warn("Failed to delete contact '\(PubkyPublicKeyFormat.redacted(contactKey))': \(error)", context: "ContactsManager") } } - if !deletedKeys.isEmpty { - contacts.removeAll { deletedKeys.contains($0.publicKey) } - } - if let firstError { + if !deletedKeys.isEmpty { + contacts.removeAll { deletedKeys.contains($0.publicKey) } + } throw firstError } + // All remote deletes succeeded, so clear any local-only contacts too. contacts.removeAll() Logger.info("Deleted all contacts", context: "ContactsManager") } @@ -617,11 +624,14 @@ class ContactsManager: ObservableObject { return try await PubkyProfileManager.resolveRemoteProfile(publicKey: prefixedKey) } catch { if includePlaceholder, Self.isMissingContactsDataError(error) { - Logger.info("No remote profile found for '\(prefixedKey)', using placeholder", context: "ContactsManager") + Logger.info( + "No remote profile found for '\(PubkyPublicKeyFormat.redacted(prefixedKey))', using placeholder", + context: "ContactsManager" + ) return PubkyProfile.placeholder(publicKey: prefixedKey) } - Logger.warn("Failed to resolve contact profile for '\(prefixedKey)': \(error)", context: "ContactsManager") + Logger.warn("Failed to resolve contact profile for '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager") throw error } } @@ -635,7 +645,7 @@ class ContactsManager: ObservableObject { } catch { if attempt == 0, !(error is CancellationError) { Logger.warn( - "Retrying imported contact profile resolution for '\(prefixedKey)' after transient error: \(error)", + "Retrying imported contact profile resolution for '\(PubkyPublicKeyFormat.redacted(prefixedKey))' after transient error: \(error)", context: "ContactsManager" ) try? await Task.sleep(nanoseconds: 250_000_000) @@ -643,7 +653,7 @@ class ContactsManager: ObservableObject { } Logger.warn( - "Falling back to placeholder while importing contact '\(prefixedKey)': \(error)", + "Falling back to placeholder while importing contact '\(PubkyPublicKeyFormat.redacted(prefixedKey))': \(error)", context: "ContactsManager" ) return PubkyProfile.placeholder(publicKey: prefixedKey) diff --git a/Bitkit/Models/ActivityDisplayConstants.swift b/Bitkit/Models/ActivityDisplayConstants.swift new file mode 100644 index 000000000..d9b610eac --- /dev/null +++ b/Bitkit/Models/ActivityDisplayConstants.swift @@ -0,0 +1,3 @@ +enum ActivityDisplayConstants { + static let maxHomeActivityItems = 4 +} diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 546092987..0bd9e5fc0 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -6,25 +6,6 @@ import LDKNode // MARK: - Activity Service class ActivityService { - private enum PubkyContactKey { - private static let prefix = "pubky" - private static let rawKeyLength = 52 - private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") - - static func normalized(_ input: String) -> String? { - let boundedInput = String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(prefix.count + rawKeyLength)) - let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput - - guard rawKey.count == rawKeyLength, - rawKey.allSatisfy({ allowedCharacters.contains($0) }) - else { - return nil - } - - return "\(prefix)\(rawKey)" - } - } - private let coreService: CoreService private let activitiesChangedSubject = PassthroughSubject() @@ -983,15 +964,15 @@ class ActivityService { } func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] { - let normalizedKey = PubkyContactKey.normalized(publicKey) ?? publicKey + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey let activities = try await get(filter: .all, sortDirection: sortDirection) return activities.filter { activity in switch activity { case let .lightning(lightning): - return lightning.contact == normalizedKey + return PubkyPublicKeyFormat.matches(lightning.contact, normalizedKey) case let .onchain(onchain): - return onchain.contact == normalizedKey + return PubkyPublicKeyFormat.matches(onchain.contact, normalizedKey) } } } @@ -1045,7 +1026,7 @@ class ActivityService { confirmTimestamp: nil, channelId: nil, transferTxId: nil, - contact: contact.map { PubkyContactKey.normalized($0) ?? $0 }, + contact: contact.map { PubkyPublicKeyFormat.normalized($0) ?? $0 }, createdAt: now, updatedAt: now, seenAt: now @@ -1061,7 +1042,7 @@ class ActivityService { } func setContact(_ publicKey: String?, forActivity id: String) async throws { - let normalizedContact = publicKey.map { PubkyContactKey.normalized($0) ?? $0 } + let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } try await ServiceQueue.background(.core) { guard let activity = try getActivityById(activityId: id) else { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index e39801743..d95053873 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -82,6 +82,11 @@ enum PublicPaykitService { } } + struct EndpointSyncPlan: Equatable { + let endpointsToSet: [Endpoint] + let methodIdsToRemove: [MethodId] + } + static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] { let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) var endpointsByMethodId: [MethodId: Endpoint] = [:] @@ -149,7 +154,7 @@ enum PublicPaykitService { static func removePublishedEndpoints() async throws { let existingMethodIds = try await currentPublishedMethodIds() - for methodId in MethodId.publishableMethodIds where existingMethodIds.contains(methodId) { + for methodId in methodIdsToRemoveWhenUnpublishing(existingMethodIds: existingMethodIds) { try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) } } @@ -196,7 +201,11 @@ enum PublicPaykitService { return endpoints.first?.paymentRequest ?? "" } - return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(bolt11Endpoint.paymentRequest)" + var allowedCharacters = CharacterSet.urlQueryAllowed + allowedCharacters.remove(charactersIn: "?&=") + let lightningPaymentRequest = bolt11Endpoint.paymentRequest + let encodedLightning = lightningPaymentRequest.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? lightningPaymentRequest + return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(encodedLightning)" } static func onchainMethodId(for address: String) -> MethodId { @@ -217,6 +226,18 @@ enum PublicPaykitService { return .bitcoinOnchainP2pkh } + static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] { + MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } + } + + static func publishedEndpointSyncPlan(existingMethodIds: Set, desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { + let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) + return EndpointSyncPlan( + endpointsToSet: desiredEndpoints, + methodIdsToRemove: MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) && !desiredMethodIds.contains($0) } + ) + } + private struct ParsedPayload { let value: String let min: String? @@ -245,18 +266,17 @@ enum PublicPaykitService { } private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { - let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) + let existingMethodIds = try await currentPublishedMethodIds() + let plan = publishedEndpointSyncPlan(existingMethodIds: existingMethodIds, desiredEndpoints: desiredEndpoints) - for endpoint in desiredEndpoints { + for endpoint in plan.endpointsToSet { try await PubkyService.setPaymentEndpoint( methodId: endpoint.methodId.rawValue, endpointData: endpoint.rawPayload ) } - let existingMethodIds = try await currentPublishedMethodIds() - - for methodId in MethodId.publishableMethodIds where existingMethodIds.contains(methodId) && !desiredMethodIds.contains(methodId) { + for methodId in plan.methodIdsToRemove { try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) } } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index a0668f06d..a7ab39536 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -137,7 +137,7 @@ class ActivityListViewModel: ObservableObject { func syncState() async { do { // Get latest activities first as that's displayed on the home view - let limitLatest: UInt32 = 4 + let limitLatest = UInt32(ActivityDisplayConstants.maxHomeActivityItems) // Fetch extra to account for potential filtering of replaced transactions let latest = try await coreService.activity.get(filter: .all, limit: limitLatest * 3) let filtered = await filterOutReplacedSentTransactions(latest) diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index e235fab12..602d4af68 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -66,15 +66,7 @@ struct AddContactView: View { .padding(.top, 24) .padding(.bottom, 16) - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 80, height: 80) - .overlay { - Text(String(publicKey.prefix(1)).uppercased()) - .font(Fonts.bold(size: 28)) - .foregroundColor(.textPrimary) - } - .accessibilityHidden(true) + ContactAvatarLetter(source: publicKey, size: 80) .padding(.bottom, 24) DisplayText(t("contacts__add_retrieving"), accentColor: .pubkyGreen) @@ -261,7 +253,10 @@ struct AddContactView: View { do { hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) } catch { - Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "AddContactView") + Logger.warn( + "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "AddContactView" + ) hasPublicPaymentEndpoint = false } } @@ -299,7 +294,7 @@ struct AddContactView: View { ) } } catch { - Logger.error("Failed to pay public pubky \(publicKey): \(error)", context: "AddContactView") + Logger.error("Failed to pay public pubky \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "AddContactView") app.toast( type: .error, title: t("slashtags__error_pay_title"), diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index 1c4e045be..34943e0d4 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -38,11 +38,11 @@ struct ContactActivityView: View { .navigationBarHidden(true) .task { resolveContactName() - await loadActivities() + await loadActivities(showLoading: true) } .onReceive(activityList.activitiesChangedPublisher) { _ in Task { - await loadActivities() + await loadActivities(showLoading: activities.isEmpty) } } .onReceive(contactsManager.$contacts) { _ in @@ -133,17 +133,27 @@ struct ContactActivityView: View { } } - private func loadActivities() async { - isLoading = true - defer { isLoading = false } + private func loadActivities(showLoading: Bool) async { + if showLoading { + isLoading = true + } + defer { + if showLoading { + isLoading = false + } + } do { activities = try await activityList.contactActivities(publicKey: publicKey) hasError = false } catch { Logger.error(error, context: "ContactActivityView") - activities = [] - hasError = true + if showLoading || activities.isEmpty { + activities = [] + hasError = true + } else { + hasError = false + } app.toast(type: .error, title: t("contacts__error_loading"), description: error.localizedDescription) } } diff --git a/Bitkit/Views/Contacts/ContactImportOverviewView.swift b/Bitkit/Views/Contacts/ContactImportOverviewView.swift index bf1a14790..3121e9a72 100644 --- a/Bitkit/Views/Contacts/ContactImportOverviewView.swift +++ b/Bitkit/Views/Contacts/ContactImportOverviewView.swift @@ -56,7 +56,6 @@ struct ContactImportOverviewView: View { // MARK: - Profile Row - @ViewBuilder private var profileRow: some View { HStack(alignment: .top, spacing: 16) { HeadlineText(profile.name) @@ -67,14 +66,7 @@ struct ContactImportOverviewView: View { if let imageUrl = profile.imageUrl { PubkyImage(uri: imageUrl, size: 64) } else { - Circle() - .fill(Color.pubkyGreen) - .frame(width: 64, height: 64) - .overlay { - Text(String(profile.name.prefix(1)).uppercased()) - .font(Fonts.bold(size: 22)) - .foregroundColor(.textPrimary) - } + ContactAvatarLetter(source: profile.name, size: 64, backgroundColor: .pubkyGreen) } } .accessibilityHidden(true) @@ -84,7 +76,6 @@ struct ContactImportOverviewView: View { // MARK: - Contacts Summary - @ViewBuilder private var contactsSummary: some View { HStack(spacing: 16) { BodyMSBText(t("contacts__import_friends_count", variables: ["count": "\(contacts.count)"])) @@ -112,9 +103,7 @@ struct ContactImportOverviewView: View { .fill(Color.gray4) .frame(width: 36, height: 36) .overlay { - Text("+\(overflow)") - .font(Fonts.bold(size: 12)) - .foregroundColor(.textPrimary) + CaptionBText("+\(overflow)", textColor: .textPrimary) } .overlay( Circle() @@ -136,24 +125,12 @@ struct ContactImportOverviewView: View { .stroke(Color.customBlack, lineWidth: 2) ) } else { - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 36, height: 36) - .overlay { - Text(String(contact.displayName.prefix(1)).uppercased()) - .font(Fonts.bold(size: 13)) - .foregroundColor(.textPrimary) - } - .overlay( - Circle() - .stroke(Color.customBlack, lineWidth: 2) - ) + ContactAvatarLetter(source: contact.displayName, size: 36, strokeColor: .customBlack, strokeWidth: 2) } } // MARK: - Button Bar - @ViewBuilder private var buttonBar: some View { HStack(spacing: 16) { CustomButton(title: t("contacts__import_select"), variant: .secondary) { diff --git a/Bitkit/Views/Contacts/ContactImportSelectView.swift b/Bitkit/Views/Contacts/ContactImportSelectView.swift index d9c34f556..4051d44e8 100644 --- a/Bitkit/Views/Contacts/ContactImportSelectView.swift +++ b/Bitkit/Views/Contacts/ContactImportSelectView.swift @@ -86,7 +86,6 @@ struct ContactImportSelectView: View { // MARK: - Checkmark - @ViewBuilder private func checkmark(isSelected: Bool) -> some View { ZStack { if isSelected { @@ -109,20 +108,12 @@ struct ContactImportSelectView: View { // MARK: - Contact Avatar - @ViewBuilder private func contactAvatar(name: String, imageUrl: String?) -> some View { Group { if let imageUrl { PubkyImage(uri: imageUrl, size: 48) } else { - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 48, height: 48) - .overlay { - Text(String(name.prefix(1)).uppercased()) - .font(Fonts.bold(size: 17)) - .foregroundColor(.textPrimary) - } + ContactAvatarLetter(source: name, size: 48) } } .accessibilityHidden(true) @@ -130,7 +121,6 @@ struct ContactImportSelectView: View { // MARK: - Footer Bar - @ViewBuilder private var footerBar: some View { VStack(spacing: 0) { CustomDivider() @@ -167,12 +157,9 @@ struct ContactImportSelectView: View { // MARK: - Pill Button - @ViewBuilder private func pillButton(title: String, isActive: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { - Text(title) - .font(Fonts.medium(size: 13)) - .foregroundColor(isActive ? .white64 : .textPrimary) + CaptionText(title, textColor: isActive ? .white64 : .textPrimary) .padding(.horizontal, 14) .padding(.vertical, 6) .background( diff --git a/Bitkit/Views/Contacts/ContactsListView.swift b/Bitkit/Views/Contacts/ContactsListView.swift index bd1c11e39..b28a4911a 100644 --- a/Bitkit/Views/Contacts/ContactsListView.swift +++ b/Bitkit/Views/Contacts/ContactsListView.swift @@ -198,14 +198,7 @@ struct ContactsListView: View { if let imageUrl { PubkyImage(uri: imageUrl, size: 48) } else { - Circle() - .fill(Color.white.opacity(0.1)) - .frame(width: 48, height: 48) - .overlay { - Text(String(name.prefix(1)).uppercased()) - .font(Fonts.bold(size: 17)) - .foregroundColor(.textPrimary) - } + ContactAvatarLetter(source: name, size: 48) } } .accessibilityHidden(true) diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 3c53cf0e1..88f606fcb 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -37,7 +37,7 @@ struct ActivityLatest: View { /// Three or four vertical slots (by screen size) shared by: transfer banner, widgets onboarding /// and activity items; only the item count shrinks so the total stays within the cap. private var maxActivityItemsOnHome: Int { - let slotCapacity = UIScreen.main.isSmall ? 3 : 4 + let slotCapacity = UIScreen.main.isSmall ? ActivityDisplayConstants.maxHomeActivityItems - 1 : ActivityDisplayConstants.maxHomeActivityItems var nonItemSlots = 0 if shouldShowBanner { nonItemSlots += 1 } if settings.showWidgets, !app.hasDismissedWidgetsOnboardingHint { nonItemSlots += 1 } diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index 517a1125b..e4c6ff7d4 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -79,7 +79,9 @@ struct SendSuccess: View { variant: .secondary, isDisabled: foundActivity == nil ) { - navigation.navigate(.activityDetail(foundActivity!)) + if let foundActivity { + navigation.navigate(.activityDetail(foundActivity)) + } sheets.hideSheet() } .accessibilityIdentifier("Details") diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 2682241b0..147b9b8cd 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -79,55 +79,28 @@ final class PublicPaykitServiceTests: XCTestCase { func testPaymentRequestCombinesOnchainAndBolt11Endpoints() { let request = PublicPaykitService.paymentRequest(from: [ - PublicPaykitService.Endpoint( - methodId: .bitcoinLightningBolt11, - value: "lnbc1invoice", - min: nil, - max: nil, - rawPayload: #"{"value":"lnbc1invoice"}"# - ), - PublicPaykitService.Endpoint( - methodId: .bitcoinOnchainP2wpkh, - value: "bc1qaddress", - min: nil, - max: nil, - rawPayload: #"{"value":"bc1qaddress"}"# - ), + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"), ]) XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice") } + func testPaymentRequestPercentEncodesLightningParameter() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice?amount=1&label=test"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qaddress"), + ]) + + XCTAssertEqual(request, "bitcoin:bc1qaddress?lightning=lnbc1invoice%3Famount%3D1%26label%3Dtest") + } + func testPaymentRequestPrefersTaprootWhenMultipleOnchainEndpointsExist() { let request = PublicPaykitService.paymentRequest(from: [ - PublicPaykitService.Endpoint( - methodId: .bitcoinLightningBolt11, - value: "lnbc1invoice", - min: nil, - max: nil, - rawPayload: #"{"value":"lnbc1invoice"}"# - ), - PublicPaykitService.Endpoint( - methodId: .bitcoinOnchainP2pkh, - value: "1legacy", - min: nil, - max: nil, - rawPayload: #"{"value":"1legacy"}"# - ), - PublicPaykitService.Endpoint( - methodId: .bitcoinOnchainP2wpkh, - value: "bc1qsegwit", - min: nil, - max: nil, - rawPayload: #"{"value":"bc1qsegwit"}"# - ), - PublicPaykitService.Endpoint( - methodId: .bitcoinOnchainP2tr, - value: "bc1ptaproot", - min: nil, - max: nil, - rawPayload: #"{"value":"bc1ptaproot"}"# - ), + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + endpoint(.bitcoinOnchainP2pkh, value: "1legacy"), + endpoint(.bitcoinOnchainP2wpkh, value: "bc1qsegwit"), + endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"), ]) XCTAssertEqual(request, "bitcoin:bc1ptaproot?lightning=lnbc1invoice") @@ -135,13 +108,7 @@ final class PublicPaykitServiceTests: XCTestCase { func testPaymentRequestFallsBackToPreferredEndpointWhenCombinedRequestIsNotAvailable() { let request = PublicPaykitService.paymentRequest(from: [ - PublicPaykitService.Endpoint( - methodId: .bitcoinLightningBolt11, - value: "lnbc1invoice", - min: nil, - max: nil, - rawPayload: #"{"value":"lnbc1invoice"}"# - ), + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), ]) XCTAssertEqual(request, "lnbc1invoice") @@ -159,4 +126,48 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(PublicPaykitPaymentLaunchResult.noEndpoint.contactPaymentFailureMessageKey, "slashtags__error_pay_empty_msg") XCTAssertEqual(PublicPaykitPaymentLaunchResult.notOpened.contactPaymentFailureMessageKey, "slashtags__error_pay_not_opened_msg") } + + func testPayableEndpointsFiltersInvalidDecodedEndpoints() async { + let payable = await PublicPaykitService.payableEndpoints(from: [ + endpoint(.bitcoinLightningBolt11, value: "not-a-bolt11"), + endpoint(.bitcoinOnchainP2tr, value: "not-an-address"), + ]) + + XCTAssertTrue(payable.isEmpty) + } + + func testMethodIdsToRemoveWhenUnpublishingOnlyIncludesPublishableEndpoints() { + let methodIds = PublicPaykitService.methodIdsToRemoveWhenUnpublishing(existingMethodIds: [ + .bitcoinLightningBolt11, + .bitcoinLightningLnurl, + .bitcoinOnchainP2tr, + ]) + + XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinOnchainP2tr]) + } + + func testPublishedEndpointSyncPlanRemovesStalePublishedMethods() { + let desired = [ + endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice"), + endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot"), + ] + + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingMethodIds: [.bitcoinLightningBolt11, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh], + desiredEndpoints: desired + ) + + XCTAssertEqual(plan.endpointsToSet, desired) + XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh]) + } + + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { + PublicPaykitService.Endpoint( + methodId: methodId, + value: value, + min: nil, + max: nil, + rawPayload: #"{"value":"\#(value)"}"# + ) + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2b4c540..c0c58fca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Support publishing spec-compliant public Paykit endpoints and paying pubky contacts or scanned pubkys through their public payment endpoints #531 - Restore pubky sessions from wallet backups and improve iOS pubky profile, contacts, and clipboard flows #527 - Pubky profile onboarding with contact sync, import, and editing #476 - Add transfer from savings button on empty spending wallet when user has on-chain balance #523 @@ -20,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix probe results and add keysend probes #522 -- Fix design: minor UI fixes #525 ## [2.2.0] - 2026-04-07 diff --git a/changelog.d/next/525.fixed.md b/changelog.d/next/525.fixed.md new file mode 100644 index 000000000..7a5c4de78 --- /dev/null +++ b/changelog.d/next/525.fixed.md @@ -0,0 +1 @@ +Fix minor UI design issues. diff --git a/changelog.d/next/531.added.md b/changelog.d/next/531.added.md new file mode 100644 index 000000000..6cfb4f239 --- /dev/null +++ b/changelog.d/next/531.added.md @@ -0,0 +1 @@ +Support publishing public Paykit endpoints and paying pubky contacts through public payment endpoints. From 98d3e047c65a7c0c899abf78065dbc28a9e87149 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 11:43:56 -0500 Subject: [PATCH 11/26] refactor: share contact payment route helper --- .../Utilities/PaymentNavigationHelper.swift | 30 +++++++++++++++++++ Bitkit/Views/Contacts/AddContactView.swift | 29 +----------------- Bitkit/Views/Contacts/ContactDetailView.swift | 29 +----------------- 3 files changed, 32 insertions(+), 56 deletions(-) diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 84b351a5a..aa53c3e52 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -146,4 +146,34 @@ struct PaymentNavigationHelper { // No valid invoice data return nil } + + static func contactPaymentRoute( + app: AppViewModel, + currency: CurrencyViewModel, + settings: SettingsViewModel + ) -> SendRoute? { + guard let route = appropriateSendRoute(app: app, currency: currency, settings: settings) else { + return nil + } + + switch route { + case .quickpay: + if app.lnurlPayData != nil { + return .lnurlPayAmount + } + + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + + return route + case .confirm: + if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + return .amount + } + return route + default: + return route + } + } } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 602d4af68..bb551670f 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -312,7 +312,7 @@ struct AddContactView: View { return false } - guard let route = contactPaymentRoute() else { + guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { return false } @@ -320,33 +320,6 @@ struct AddContactView: View { sheets.showSheet(.send, data: SendConfig(view: route)) return true } - - @MainActor - private func contactPaymentRoute() -> SendRoute? { - guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else { - return nil - } - - switch route { - case .quickpay: - if app.lnurlPayData != nil { - return .lnurlPayAmount - } - - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount - } - - return route - case .confirm: - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount - } - return route - default: - return route - } - } } #Preview { diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 42d5c09f5..836ae70ba 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -323,7 +323,7 @@ struct ContactDetailView: View { return false } - guard let route = contactPaymentRoute() else { + guard let route = PaymentNavigationHelper.contactPaymentRoute(app: app, currency: currency, settings: settings) else { return false } @@ -331,33 +331,6 @@ struct ContactDetailView: View { sheets.showSheet(.send, data: SendConfig(view: route)) return true } - - @MainActor - private func contactPaymentRoute() -> SendRoute? { - guard let route = PaymentNavigationHelper.appropriateSendRoute(app: app, currency: currency, settings: settings) else { - return nil - } - - switch route { - case .quickpay: - if app.lnurlPayData != nil { - return .lnurlPayAmount - } - - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount - } - - return route - case .confirm: - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount - } - return route - default: - return route - } - } } #Preview { From c430f9af2611b46c83529d6dc6780d3b31b26a8a Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 12:00:45 -0500 Subject: [PATCH 12/26] fix: include pubky key format in shared targets --- Bitkit.xcodeproj/project.pbxproj | 2 + Bitkit/Managers/ContactsManager.swift | 40 -------------------- Bitkit/Models/PubkyPublicKeyFormat.swift | 47 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 40 deletions(-) create mode 100644 Bitkit/Models/PubkyPublicKeyFormat.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index de85746e2..7ba95d9b4 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -87,6 +87,7 @@ Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, + Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, Models/Transfer.swift, Models/TransferType.swift, @@ -117,6 +118,7 @@ Extensions/PaymentDetails.swift, Models/BlocktankNotificationType.swift, Models/LnPeer.swift, + Models/PubkyPublicKeyFormat.swift, Models/Toast.swift, Services/CoreService.swift, Services/GeoService.swift, diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 249534995..bb5599653 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -11,46 +11,6 @@ private func stripPubkyPrefix(_ key: String) -> String { key.hasPrefix(pubkyPrefix) ? String(key.dropFirst(pubkyPrefix.count)) : key } -enum PubkyPublicKeyFormat { - private static let rawKeyLength = 52 - private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") - - static let maximumInputLength = pubkyPrefix.count + rawKeyLength - - static func bounded(_ input: String) -> String { - String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength)) - } - - static func normalized(_ input: String) -> String? { - let boundedInput = bounded(input) - let rawKey = stripPubkyPrefix(boundedInput) - - guard rawKey.count == rawKeyLength else { - return nil - } - - guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else { - return nil - } - - return ensurePubkyPrefix(rawKey) - } - - static func matches(_ lhs: String?, _ rhs: String?) -> Bool { - guard let lhs = lhs.flatMap(normalized), - let rhs = rhs.flatMap(normalized) - else { - return false - } - - return lhs == rhs - } - - static func redacted(_ input: String) -> String { - (normalized(input) ?? input.trimmingCharacters(in: .whitespacesAndNewlines)).ellipsis(maxLength: 12, style: .end) - } -} - enum AddContactValidationResult: Equatable { case empty case existingContact diff --git a/Bitkit/Models/PubkyPublicKeyFormat.swift b/Bitkit/Models/PubkyPublicKeyFormat.swift new file mode 100644 index 000000000..64e0c86e4 --- /dev/null +++ b/Bitkit/Models/PubkyPublicKeyFormat.swift @@ -0,0 +1,47 @@ +import Foundation + +enum PubkyPublicKeyFormat { + private static let prefix = "pubky" + private static let rawKeyLength = 52 + private static let allowedCharacters = Set("ybndrfg8ejkmcpqxot1uwisza345h769") + + static let maximumInputLength = prefix.count + rawKeyLength + + static func bounded(_ input: String) -> String { + String(input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().prefix(maximumInputLength)) + } + + static func normalized(_ input: String) -> String? { + let boundedInput = bounded(input) + let rawKey = boundedInput.hasPrefix(prefix) ? String(boundedInput.dropFirst(prefix.count)) : boundedInput + + guard rawKey.count == rawKeyLength else { + return nil + } + + guard rawKey.allSatisfy({ allowedCharacters.contains($0) }) else { + return nil + } + + return "\(prefix)\(rawKey)" + } + + static func matches(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs = lhs.flatMap(normalized), + let rhs = rhs.flatMap(normalized) + else { + return false + } + + return lhs == rhs + } + + static func redacted(_ input: String) -> String { + let value = normalized(input) ?? input.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.count > 12 else { + return value + } + + return "\(value.prefix(12))..." + } +} From 2249d9d51fd04d98935c8905839eeb937851841f Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 12:10:54 -0500 Subject: [PATCH 13/26] fix: address public payment review followups --- Bitkit/Managers/ContactsManager.swift | 2 +- Bitkit/Services/CoreService.swift | 1 + Bitkit/Services/PublicPaykitService.swift | 25 ++++++++++++---- Bitkit/Services/ZipService.swift | 4 ++- Bitkit/ViewModels/AppViewModel.swift | 4 +++ .../Wallets/Activity/AllActivityView.swift | 24 +++++++++++---- Bitkit/Views/Wallets/Send/SendSheet.swift | 1 + Bitkit/Views/Wallets/Send/SendSuccess.swift | 2 +- BitkitTests/PublicPaykitServiceTests.swift | 30 ++++++++++++++++++- BitkitTests/ZipServiceTests.swift | 14 +++++++++ 10 files changed, 92 insertions(+), 15 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index bb5599653..a8c478601 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -77,7 +77,7 @@ enum ContactsManagerError: LocalizedError { // MARK: - PubkyContact -struct PubkyContact: Identifiable, Hashable { +struct PubkyContact: Identifiable, Hashable, Sendable { let id: String let publicKey: String let profile: PubkyProfile diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 0bd9e5fc0..384425a06 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -965,6 +965,7 @@ class ActivityService { func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] { let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + // TODO: push contact filtering into BitkitCore once the activity store exposes it. let activities = try await get(filter: .all, sortDirection: sortDirection) return activities.filter { activity in diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index d95053873..947eeebdd 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -230,11 +230,11 @@ enum PublicPaykitService { MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } } - static func publishedEndpointSyncPlan(existingMethodIds: Set, desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { + static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { let desiredMethodIds = Set(desiredEndpoints.map(\.methodId)) return EndpointSyncPlan( - endpointsToSet: desiredEndpoints, - methodIdsToRemove: MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) && !desiredMethodIds.contains($0) } + endpointsToSet: desiredEndpoints.filter { existingEndpoints[$0.methodId] != $0.rawPayload }, + methodIdsToRemove: MethodId.publishableMethodIds.filter { existingEndpoints[$0] != nil && !desiredMethodIds.contains($0) } ) } @@ -266,8 +266,8 @@ enum PublicPaykitService { } private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { - let existingMethodIds = try await currentPublishedMethodIds() - let plan = publishedEndpointSyncPlan(existingMethodIds: existingMethodIds, desiredEndpoints: desiredEndpoints) + let existingEndpoints = try await currentPublishedEndpoints() + let plan = publishedEndpointSyncPlan(existingEndpoints: existingEndpoints, desiredEndpoints: desiredEndpoints) for endpoint in plan.endpointsToSet { try await PubkyService.setPaymentEndpoint( @@ -282,12 +282,25 @@ enum PublicPaykitService { } private static func currentPublishedMethodIds() async throws -> Set { + Set((try await currentPublishedEndpoints()).keys) + } + + private static func currentPublishedEndpoints() async throws -> [MethodId: String] { guard let publicKey = await PubkyService.currentPublicKey() else { throw PubkyServiceError.sessionNotActive } let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) - return Set(paymentEntries.compactMap { MethodId(rawValue: $0.methodId) }) + var endpoints: [MethodId: String] = [:] + for entry in paymentEntries { + guard let methodId = MethodId(rawValue: entry.methodId) else { + continue + } + + endpoints[methodId] = entry.endpointData + } + + return endpoints } @MainActor diff --git a/Bitkit/Services/ZipService.swift b/Bitkit/Services/ZipService.swift index 1a812bc47..29e841842 100644 --- a/Bitkit/Services/ZipService.swift +++ b/Bitkit/Services/ZipService.swift @@ -64,12 +64,14 @@ extension FileToZip { } private static func sanitizedFilename(_ filename: String) -> String { - filename + let sanitized = filename .replacingOccurrences(of: "\\", with: "/") .split(separator: "/") .last .map(String.init)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + return sanitized == "." || sanitized == ".." ? "" : sanitized } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index a38548559..af1e5093c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -310,6 +310,10 @@ extension AppViewModel { pendingContactPaymentContexts.removeValue(forKey: hash) } + func clearPendingContactPaymentContexts() { + pendingContactPaymentContexts.removeAll() + } + /// Called by SendPendingScreen when it consumes a resolution. Clears the published value. func consumeSendSheetPendingResolution(paymentHash hash: String) { guard sendSheetPendingResolution?.paymentHash == hash else { return } diff --git a/Bitkit/Views/Wallets/Activity/AllActivityView.swift b/Bitkit/Views/Wallets/Activity/AllActivityView.swift index 0939b097d..64832e763 100644 --- a/Bitkit/Views/Wallets/Activity/AllActivityView.swift +++ b/Bitkit/Views/Wallets/Activity/AllActivityView.swift @@ -5,17 +5,16 @@ struct AllActivityView: View { @EnvironmentObject private var app: AppViewModel @EnvironmentObject private var wallet: WalletViewModel + @State private var headerContentHeight: CGFloat = 116 + private var headerTopPadding: CGFloat { - // NavBar + Filter + SegmentedControl + spacing - return ScreenLayout.topPaddingWithoutSafeArea + 116 + ScreenLayout.topPaddingWithoutSafeArea + headerContentHeight } var body: some View { ZStack(alignment: .top) { - // ScrollView - base layer, full height, content scrolls behind header ScrollView(showsIndicators: false) { ActivityList(viewType: .all) - // .padding(.top, headerTopPadding) .scrollDismissesKeyboard(.interactively) .highPriorityGesture( // TODO: rewrite using TabView @@ -62,7 +61,6 @@ struct AllActivityView: View { } .transition(.move(edge: .leading).combined(with: .opacity)) - // Header - overlay on top, scroll content goes behind it VStack(spacing: 0) { NavigationBar(title: t("wallet__activity")) .padding(.bottom, 16) @@ -72,6 +70,14 @@ struct AllActivityView: View { SegmentedControl(selectedTab: $activity.selectedTab, tabs: ActivityTab.allCases) } + .background( + GeometryReader { proxy in + Color.clear.preference(key: ActivityHeaderHeightPreferenceKey.self, value: proxy.size.height) + } + ) + .onPreferenceChange(ActivityHeaderHeightPreferenceKey.self) { height in + headerContentHeight = height + } .frame(maxWidth: .infinity, alignment: .top) .padding(.horizontal, 16) .background( @@ -112,6 +118,14 @@ struct AllActivityView: View { } } +private struct ActivityHeaderHeightPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 116 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + #Preview { NavigationStack { AllActivityView() diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index f09fab6f1..6e1c2fa03 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -110,6 +110,7 @@ struct SendSheet: View { } .onDisappear { app.contactPaymentContext = nil + app.clearPendingContactPaymentContexts() } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift index e4c6ff7d4..c2170f0f6 100644 --- a/Bitkit/Views/Wallets/Send/SendSuccess.swift +++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift @@ -127,7 +127,7 @@ struct SendSuccess: View { } do { - try await activityListViewModel.setContact(contactPublicKey, forPaymentId: paymentId) + try await activityListViewModel.setContact(contactPublicKey, forPaymentId: paymentId, syncLdkPayments: false) app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { Logger.warn("Failed to set pending contact for payment \(paymentId): \(error)", context: "SendSuccess") diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 147b9b8cd..600fad316 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -114,6 +114,14 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(request, "lnbc1invoice") } + func testPaymentRequestFallsBackToLnurlOnlyEndpoint() { + let request = PublicPaykitService.paymentRequest(from: [ + endpoint(.bitcoinLightningLnurl, value: "lnurl1example"), + ]) + + XCTAssertEqual(request, "lnurl1example") + } + func testOnchainMethodIdUsesAddressPrefix() { XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample"), .bitcoinOnchainP2tr) XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample"), .bitcoinOnchainP2wpkh) @@ -153,7 +161,11 @@ final class PublicPaykitServiceTests: XCTestCase { ] let plan = PublicPaykitService.publishedEndpointSyncPlan( - existingMethodIds: [.bitcoinLightningBolt11, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh], + existingEndpoints: [ + .bitcoinLightningBolt11: #"{"value":"oldinvoice"}"#, + .bitcoinOnchainP2wpkh: #"{"value":"bc1qsegwit"}"#, + .bitcoinOnchainP2sh: #"{"value":"3nested"}"#, + ], desiredEndpoints: desired ) @@ -161,6 +173,22 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(plan.methodIdsToRemove, [.bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh]) } + func testPublishedEndpointSyncPlanSkipsUnchangedPublishedPayloads() { + let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice") + let taproot = endpoint(.bitcoinOnchainP2tr, value: "bc1ptaproot") + + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingEndpoints: [ + .bitcoinLightningBolt11: bolt11.rawPayload, + .bitcoinOnchainP2tr: #"{"value":"oldtaproot"}"#, + ], + desiredEndpoints: [bolt11, taproot] + ) + + XCTAssertEqual(plan.endpointsToSet, [taproot]) + XCTAssertTrue(plan.methodIdsToRemove.isEmpty) + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, diff --git a/BitkitTests/ZipServiceTests.swift b/BitkitTests/ZipServiceTests.swift index 59af86ddf..573cfeb69 100644 --- a/BitkitTests/ZipServiceTests.swift +++ b/BitkitTests/ZipServiceTests.swift @@ -48,6 +48,20 @@ final class ZipServiceTests: XCTestCase { } } + func testCreateZipRejectsDotFilenames() throws { + let zipService = ZipService() + + for filename in [".", "..", "logs/.."] { + let filesToZip: [FileToZip] = [.data(Data([0x01]), filename: filename)] + + XCTAssertThrowsError(try zipService.getZipData(zipFilename: "invalid", filesToZip: filesToZip)) { error in + guard case CreateZipError.invalidFilename = error else { + return XCTFail("Expected invalidFilename error for \(filename), got \(error)") + } + } + } + } + func testCreateZipOverwritesExistingFileByDefault() throws { let sourceDirectory = testRootDirectoryURL.appendingPathComponent("input") try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) From 7d48489b2bcad4d8d1f94a6944de9f6cd499f3bb Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:12:05 -0500 Subject: [PATCH 14/26] fix: preserve contact context for fast payments --- Bitkit/Services/CoreService.swift | 2 +- Bitkit/ViewModels/AppViewModel.swift | 5 +++++ Bitkit/Views/Wallets/Send/SendConfirmationView.swift | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 384425a06..5874326da 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1047,7 +1047,7 @@ class ActivityService { try await ServiceQueue.background(.core) { guard let activity = try getActivityById(activityId: id) else { - return + throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found") } switch activity { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index af1e5093c..214a60d22 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -302,6 +302,11 @@ extension AppViewModel { } } + func addPendingContactPaymentContext(_ hash: String, contactPublicKey: String?) { + guard let contactPublicKey else { return } + pendingContactPaymentContexts[hash] = ContactPaymentContext(publicKey: contactPublicKey) + } + func contactPaymentContext(forPendingPaymentHash hash: String) -> ContactPaymentContext? { pendingContactPaymentContexts[hash] } diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 37d7ae22f..98f5a2c47 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -536,6 +536,7 @@ struct SendConfirmationView: View { } do { + app.addPendingContactPaymentContext(paymentId, contactPublicKey: contactPublicKey) try await activityList.setContact(contactPublicKey, forPaymentId: paymentId) app.consumeContactPaymentContext(forPendingPaymentHash: paymentId) } catch { From 73971b6e78b9d7dff1c47cadcabf5b58720675da Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:24:00 -0500 Subject: [PATCH 15/26] fix: isolate public paykit receive invoice --- Bitkit/Services/PublicPaykitService.swift | 10 +++-- Bitkit/ViewModels/WalletViewModel.swift | 49 +++++++++++++++++----- Bitkit/Views/Profile/PayContactsView.swift | 6 +-- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 947eeebdd..87723f761 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -282,7 +282,8 @@ enum PublicPaykitService { } private static func currentPublishedMethodIds() async throws -> Set { - Set((try await currentPublishedEndpoints()).keys) + let endpoints = try await currentPublishedEndpoints() + return Set(endpoints.keys) } private static func currentPublishedEndpoints() async throws -> [MethodId: String] { @@ -312,12 +313,13 @@ enum PublicPaykitService { throw PublicPaykitError.walletNotReady } - try await wallet.refreshBip21(forceRefreshBolt11: true) + _ = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: true) } + let publicEndpoints = try await wallet.refreshPublicPaykitEndpoints(forceRefreshBolt11: false) var endpoints: [Endpoint] = [] - let onchainAddress = wallet.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines) + let onchainAddress = publicEndpoints.onchainAddress.trimmingCharacters(in: .whitespacesAndNewlines) if !onchainAddress.isEmpty { try endpoints.append( Endpoint( @@ -330,7 +332,7 @@ enum PublicPaykitService { ) } - let bolt11 = wallet.bolt11.trimmingCharacters(in: .whitespacesAndNewlines) + let bolt11 = publicEndpoints.bolt11.trimmingCharacters(in: .whitespacesAndNewlines) if !bolt11.isEmpty { try endpoints.append( Endpoint( diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index e5a70aae2..80f68e33d 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -17,6 +17,7 @@ class WalletViewModel: ObservableObject { @AppStorage("onchainAddress") var onchainAddress = "" @AppStorage("bolt11") var bolt11 = "" @AppStorage("bip21") var bip21 = "" + @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = "" @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI // Send flow @@ -929,16 +930,8 @@ class WalletViewModel: ObservableObject { return channels?.contains(where: \.isUsable) ?? false } - func refreshBip21(forceRefreshBolt11: Bool = false) async throws { - // Get old payment ID and tags before refreshing (which may change payment ID) - let oldPaymentId = await paymentId() - var tagsToMigrate: [String] = [] - if let oldPaymentId, !oldPaymentId.isEmpty { - if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) { - tagsToMigrate = oldMetadata.tags - } - } - + @discardableResult + private func refreshReusableOnchainAddress() async throws -> String { if onchainAddress.isEmpty { onchainAddress = try await lightningService.newAddress() } else { @@ -951,6 +944,41 @@ class WalletViewModel: ObservableObject { } } + return onchainAddress + } + + func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) { + let publicOnchainAddress = try await refreshReusableOnchainAddress() + + if hasReadyChannels { + if forceRefreshBolt11 || publicPaykitBolt11.isEmpty { + publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") + } else if case let .lightning(lightningInvoice) = try? await decode(invoice: publicPaykitBolt11) { + if lightningInvoice.isExpired || lightningInvoice.amountSatoshis > 0 || !lightningInvoice.description.isEmpty { + publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") + } + } else { + publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") + } + } else { + publicPaykitBolt11 = "" + } + + return (publicOnchainAddress, publicPaykitBolt11) + } + + func refreshBip21(forceRefreshBolt11: Bool = false) async throws { + // Get old payment ID and tags before refreshing (which may change payment ID) + let oldPaymentId = await paymentId() + var tagsToMigrate: [String] = [] + if let oldPaymentId, !oldPaymentId.isEmpty { + if let oldMetadata = try? await coreService.activity.getPreActivityMetadata(searchKey: oldPaymentId, searchByAddress: false) { + tagsToMigrate = oldMetadata.tags + } + } + + try await refreshReusableOnchainAddress() + var newBip21 = "bitcoin:\(onchainAddress)" let amountSats = invoiceAmountSats > 0 ? invoiceAmountSats : nil @@ -1163,6 +1191,7 @@ class WalletViewModel: ObservableObject { onchainAddress = "" bolt11 = "" bip21 = "" + publicPaykitBolt11 = "" try? await coreService.activity.removeAll() diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index b1bbb660a..8f6b9a21f 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -8,7 +8,7 @@ struct PayContactsView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel - @State private var enablePayments = true + @State private var enablePayments = false @State private var isSaving = false var body: some View { @@ -60,7 +60,7 @@ struct PayContactsView: View { .background(Color.customBlack) .navigationBarHidden(true) .onAppear { - enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : false } } @@ -74,7 +74,7 @@ struct PayContactsView: View { hasConfirmedPublicPaykitEndpoints = true navigation.path = [.profile] } catch { - enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : false Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView") app.toast( type: .error, From 30ce34461f1e0f68765b4ba5717ea56fbe456e00 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:33:31 -0500 Subject: [PATCH 16/26] fix: address public paykit review blockers --- Bitkit/Services/PublicPaykitService.swift | 2 +- .../Utilities/PaymentNavigationHelper.swift | 10 ++++-- Bitkit/ViewModels/AppViewModel.swift | 4 --- .../Views/Contacts/ContactActivityView.swift | 32 +++++++++---------- Bitkit/Views/Contacts/ContactDetailView.swift | 7 ++-- Bitkit/Views/Profile/PayContactsView.swift | 6 ++-- Bitkit/Views/Wallets/Send/SendSheet.swift | 1 - BitkitTests/PublicPaykitServiceTests.swift | 4 +-- CHANGELOG.md | 1 + changelog.d/next/525.fixed.md | 1 - 10 files changed, 35 insertions(+), 33 deletions(-) delete mode 100644 changelog.d/next/525.fixed.md diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 87723f761..a3ed7b4b6 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -227,7 +227,7 @@ enum PublicPaykitService { } static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] { - MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } + MethodId.payablePreferenceOrder.filter { existingMethodIds.contains($0) } } static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index aa53c3e52..46ddf9953 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -162,14 +162,18 @@ struct PaymentNavigationHelper { return .lnurlPayAmount } - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { + if let invoice = app.scannedLightningInvoice { + return invoice.amountSatoshis == 0 ? .amount : .confirm + } + + if app.scannedOnchainInvoice != nil { return .amount } return route case .confirm: - if app.scannedLightningInvoice != nil || app.scannedOnchainInvoice != nil { - return .amount + if let invoice = app.scannedLightningInvoice { + return invoice.amountSatoshis == 0 ? .amount : .confirm } return route default: diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 214a60d22..75252920f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -315,10 +315,6 @@ extension AppViewModel { pendingContactPaymentContexts.removeValue(forKey: hash) } - func clearPendingContactPaymentContexts() { - pendingContactPaymentContexts.removeAll() - } - /// Called by SendPendingScreen when it consumes a resolution. Clears the published value. func consumeSendSheetPendingResolution(paymentHash hash: String) { guard sendSheetPendingResolution?.paymentHash == hash else { return } diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index 34943e0d4..e908c5742 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -64,7 +64,7 @@ struct ContactActivityView: View { ActivityRow( item: activity, feeEstimates: feeEstimatesManager.estimates, - titleOverride: activityTitle(activity) + contact: activityContact ) } .accessibilityIdentifier("ContactActivity-\(index)") @@ -113,24 +113,22 @@ struct ContactActivityView: View { return publicKey.ellipsis(maxLength: 18) } - private func resolveContactName() { - contactName = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })?.displayName ?? "" + private var activityContact: PubkyContact { + PubkyContact( + publicKey: publicKey, + profile: PubkyProfile( + publicKey: publicKey, + name: contactDisplayName, + bio: "", + imageUrl: nil, + links: [], + status: nil + ) + ) } - private func activityTitle(_ activity: Activity) -> String { - let txType: PaymentType = switch activity { - case let .lightning(lightningActivity): - lightningActivity.txType - case let .onchain(onchainActivity): - onchainActivity.txType - } - - switch txType { - case .sent: - return t("contacts__activity_sent_to", variables: ["name": contactDisplayName]) - case .received: - return t("contacts__activity_received_from", variables: ["name": contactDisplayName]) - } + private func resolveContactName() { + contactName = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) })?.displayName ?? "" } private func loadActivities(showLoading: Bool) async { diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 836ae70ba..19ba58355 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -272,7 +272,10 @@ struct ContactDetailView: View { do { hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) } catch { - Logger.warn("Failed to load public payment endpoints for \(publicKey): \(error)", context: "ContactDetailView") + Logger.warn( + "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "ContactDetailView" + ) hasPublicPaymentEndpoint = false } } @@ -305,7 +308,7 @@ struct ContactDetailView: View { ) } } catch { - Logger.error("Failed to pay contact \(publicKey): \(error)", context: "ContactDetailView") + Logger.error("Failed to pay contact \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "ContactDetailView") app.toast( type: .error, title: t("slashtags__error_pay_title"), diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 8f6b9a21f..eac79d351 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -45,6 +45,7 @@ struct PayContactsView: View { BodyMText(t("profile__pay_contacts_toggle"), textColor: .white) } .tint(.pubkyGreen) + .disabled(isSaving) .accessibilityIdentifier("PayContactsToggle") .padding(.horizontal, 32) @@ -65,12 +66,13 @@ struct PayContactsView: View { } private func continueFlow() async { + let publish = enablePayments isSaving = true defer { isSaving = false } do { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: enablePayments) - sharesPublicPaykitEndpoints = enablePayments + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish) + sharesPublicPaykitEndpoints = publish hasConfirmedPublicPaykitEndpoints = true navigation.path = [.profile] } catch { diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift index 6e1c2fa03..f09fab6f1 100644 --- a/Bitkit/Views/Wallets/Send/SendSheet.swift +++ b/Bitkit/Views/Wallets/Send/SendSheet.swift @@ -110,7 +110,6 @@ struct SendSheet: View { } .onDisappear { app.contactPaymentContext = nil - app.clearPendingContactPaymentContexts() } .onChange(of: wallet.nodeLifecycleState) { _, state in // When the node becomes running and we have a scanned invoice, run deferred validation. diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 600fad316..f8f2649b6 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -144,14 +144,14 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertTrue(payable.isEmpty) } - func testMethodIdsToRemoveWhenUnpublishingOnlyIncludesPublishableEndpoints() { + func testMethodIdsToRemoveWhenUnpublishingIncludesAllPayableEndpoints() { let methodIds = PublicPaykitService.methodIdsToRemoveWhenUnpublishing(existingMethodIds: [ .bitcoinLightningBolt11, .bitcoinLightningLnurl, .bitcoinOnchainP2tr, ]) - XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinOnchainP2tr]) + XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinLightningLnurl, .bitcoinOnchainP2tr]) } func testPublishedEndpointSyncPlanRemovesStalePublishedMethods() { diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c58fca4..720679832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fix probe results and add keysend probes #522 +- Fix design: minor UI fixes #525 ## [2.2.0] - 2026-04-07 diff --git a/changelog.d/next/525.fixed.md b/changelog.d/next/525.fixed.md deleted file mode 100644 index 7a5c4de78..000000000 --- a/changelog.d/next/525.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix minor UI design issues. From 07b7a2a3190f3e4e964271db2e9a55014cc97673 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 13:36:47 -0500 Subject: [PATCH 17/26] chore: align pay contacts lifecycle hook --- Bitkit/Views/Profile/PayContactsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index eac79d351..19cb46f5e 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -60,7 +60,7 @@ struct PayContactsView: View { .bottomSafeAreaPadding() .background(Color.customBlack) .navigationBarHidden(true) - .onAppear { + .task { enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : false } } From fac5074fa28ff309b7a26a0716dc95c8435ac376 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 14:06:55 -0500 Subject: [PATCH 18/26] fix: handle optional public invoice description --- Bitkit/ViewModels/WalletViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 80f68e33d..ca55c28c6 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -954,7 +954,7 @@ class WalletViewModel: ObservableObject { if forceRefreshBolt11 || publicPaykitBolt11.isEmpty { publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") } else if case let .lightning(lightningInvoice) = try? await decode(invoice: publicPaykitBolt11) { - if lightningInvoice.isExpired || lightningInvoice.amountSatoshis > 0 || !lightningInvoice.description.isEmpty { + if lightningInvoice.isExpired || lightningInvoice.amountSatoshis > 0 || !(lightningInvoice.description ?? "").isEmpty { publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") } } else { From e037fe7e5d162560e1244937b4397832977a2042 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 15:05:43 -0500 Subject: [PATCH 19/26] fix: align public paykit endpoint lifecycle --- Bitkit/AppScene.swift | 1 + Bitkit/Managers/PubkyProfileManager.swift | 13 +++- Bitkit/Services/PublicPaykitService.swift | 2 +- Bitkit/Utilities/AppReset.swift | 2 + Bitkit/ViewModels/WalletViewModel.swift | 71 ++++++++++++++++++---- BitkitTests/PublicPaykitServiceTests.swift | 18 +++++- 6 files changed, 92 insertions(+), 15 deletions(-) diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index a6cc9f347..efe055364 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -560,6 +560,7 @@ struct AppScene: View { Task { await clearDeliveredNotifications() await LightningService.shared.reconnectPeers() + await wallet.refreshPublicPaykitEndpointsOnForeground() } } } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 330057cee..552418691 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -22,7 +22,7 @@ private enum PubkyProfileManagerError: LocalizedError { @MainActor class PubkyProfileManager: ObservableObject { - enum SessionInitializationResult: Equatable, Sendable { + enum SessionInitializationResult: Equatable { case noSession case restored(publicKey: String) case restorationFailed @@ -555,8 +555,19 @@ class PubkyProfileManager: ObservableObject { notifyAppStateBackupChanged() } + static func removePublicPaykitEndpointsBestEffort(context: String) async { + do { + try await PublicPaykitService.removePublishedEndpoints() + } catch PubkyServiceError.sessionNotActive { + Logger.debug("Skipping public Paykit endpoint cleanup because no session is active", context: context) + } catch { + Logger.warn("Failed to remove public Paykit endpoints before clearing session: \(error)", context: context) + } + } + func signOut() async { await Task.detached { + await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") do { try await PubkyService.signOut() } catch { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index a3ed7b4b6..87723f761 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -227,7 +227,7 @@ enum PublicPaykitService { } static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] { - MethodId.payablePreferenceOrder.filter { existingMethodIds.contains($0) } + MethodId.publishableMethodIds.filter { existingMethodIds.contains($0) } } static func publishedEndpointSyncPlan(existingEndpoints: [MethodId: String], desiredEndpoints: [Endpoint]) -> EndpointSyncPlan { diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index ac5de5933..0618b33b9 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -20,6 +20,8 @@ enum AppReset { await VssBackupClient.shared.reset() VssStoreIdProvider.shared.clearCache() + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") + // Stop node and wipe LDK persistence via the wallet API. try await wallet.wipe() diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ca55c28c6..be717d900 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -18,6 +18,8 @@ class WalletViewModel: ObservableObject { @AppStorage("bolt11") var bolt11 = "" @AppStorage("bip21") var bip21 = "" @AppStorage("publicPaykitBolt11") var publicPaykitBolt11 = "" + @AppStorage("publicPaykitBolt11PaymentHash") var publicPaykitBolt11PaymentHash = "" + @AppStorage("publicPaykitBolt11ExpiresAt") var publicPaykitBolt11ExpiresAt = 0.0 @AppStorage("channelCount") var channelCount: Int = 0 // Keeping a cached version of this so we can better anticipate the receive flow UI // Send flow @@ -59,6 +61,8 @@ class WalletViewModel: ObservableObject { @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false + private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 15 * 60 + private let lightningService: LightningService private let coreService: CoreService private let electrumConfigService: ElectrumConfigService @@ -202,7 +206,14 @@ class WalletViewModel: ObservableObject { paymentHash: paymentHash, shortChannelId: shortChannelId ) - case .paymentReceived, .channelReady: + case let .paymentReceived(paymentId, paymentHash, _, _): + self.bolt11 = "" + self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentId ?? paymentHash) + Task { + await self.refreshAndSyncState() + try? await self.refreshBip21() + } + case .channelReady: self.bolt11 = "" Task { await self.refreshAndSyncState() @@ -951,22 +962,60 @@ class WalletViewModel: ObservableObject { let publicOnchainAddress = try await refreshReusableOnchainAddress() if hasReadyChannels { - if forceRefreshBolt11 || publicPaykitBolt11.isEmpty { - publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") - } else if case let .lightning(lightningInvoice) = try? await decode(invoice: publicPaykitBolt11) { - if lightningInvoice.isExpired || lightningInvoice.amountSatoshis > 0 || !(lightningInvoice.description ?? "").isEmpty { - publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") - } - } else { - publicPaykitBolt11 = try await createInvoice(amountSats: nil, note: "") + if await forceRefreshBolt11 || !hasReusablePublicPaykitInvoice() { + try await refreshPublicPaykitBolt11() } } else { - publicPaykitBolt11 = "" + clearPublicPaykitBolt11() } return (publicOnchainAddress, publicPaykitBolt11) } + func refreshPublicPaykitEndpointsOnForeground() async { + guard sharesPublicPaykitEndpoints else { return } + + do { + try await PublicPaykitService.syncCurrentPublishedEndpoints(wallet: self) + } catch { + Logger.warn("Failed to refresh public Paykit endpoints on foreground: \(error)", context: "WalletViewModel") + } + } + + private func hasReusablePublicPaykitInvoice() async -> Bool { + guard !publicPaykitBolt11.isEmpty else { return false } + 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 + } + + private func refreshPublicPaykitBolt11() async throws { + let invoice = try await createInvoice(amountSats: nil, note: "") + guard case let .lightning(lightningInvoice) = try await decode(invoice: invoice) else { + clearPublicPaykitBolt11() + throw PublicPaykitError.invalidPayload + } + + publicPaykitBolt11 = invoice + publicPaykitBolt11PaymentHash = lightningInvoice.paymentHash.hex + publicPaykitBolt11ExpiresAt = Double(lightningInvoice.timestampSeconds + lightningInvoice.expirySeconds) + } + + private func clearPublicPaykitBolt11() { + publicPaykitBolt11 = "" + publicPaykitBolt11PaymentHash = "" + publicPaykitBolt11ExpiresAt = 0 + } + + private func rotatePublicPaykitInvoiceIfNeeded(paymentHash: String) { + guard !publicPaykitBolt11PaymentHash.isEmpty, + publicPaykitBolt11PaymentHash == paymentHash + else { return } + + clearPublicPaykitBolt11() + } + func refreshBip21(forceRefreshBolt11: Bool = false) async throws { // Get old payment ID and tags before refreshing (which may change payment ID) let oldPaymentId = await paymentId() @@ -1191,7 +1240,7 @@ class WalletViewModel: ObservableObject { onchainAddress = "" bolt11 = "" bip21 = "" - publicPaykitBolt11 = "" + clearPublicPaykitBolt11() try? await coreService.activity.removeAll() diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index f8f2649b6..e4be4e8ba 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -144,14 +144,14 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertTrue(payable.isEmpty) } - func testMethodIdsToRemoveWhenUnpublishingIncludesAllPayableEndpoints() { + func testMethodIdsToRemoveWhenUnpublishingOnlyIncludesBitkitManagedEndpoints() { let methodIds = PublicPaykitService.methodIdsToRemoveWhenUnpublishing(existingMethodIds: [ .bitcoinLightningBolt11, .bitcoinLightningLnurl, .bitcoinOnchainP2tr, ]) - XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinLightningLnurl, .bitcoinOnchainP2tr]) + XCTAssertEqual(methodIds, [.bitcoinLightningBolt11, .bitcoinOnchainP2tr]) } func testPublishedEndpointSyncPlanRemovesStalePublishedMethods() { @@ -189,6 +189,20 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertTrue(plan.methodIdsToRemove.isEmpty) } + func testPublishedEndpointSyncPlanPreservesExternallyOwnedLnurlEndpoint() { + let bolt11 = endpoint(.bitcoinLightningBolt11, value: "lnbc1invoice") + + let plan = PublicPaykitService.publishedEndpointSyncPlan( + existingEndpoints: [ + .bitcoinLightningLnurl: #"{"value":"lnurl1external"}"#, + ], + desiredEndpoints: [bolt11] + ) + + XCTAssertEqual(plan.endpointsToSet, [bolt11]) + XCTAssertTrue(plan.methodIdsToRemove.isEmpty) + } + private func endpoint(_ methodId: PublicPaykitService.MethodId, value: String) -> PublicPaykitService.Endpoint { PublicPaykitService.Endpoint( methodId: methodId, From 1b7c1407fb009954a009aad562250535086f4b29 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 15:24:50 -0500 Subject: [PATCH 20/26] fix: avoid async autoclosure in paykit refresh --- Bitkit/ViewModels/WalletViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index be717d900..828a8c325 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -962,7 +962,9 @@ class WalletViewModel: ObservableObject { let publicOnchainAddress = try await refreshReusableOnchainAddress() if hasReadyChannels { - if await forceRefreshBolt11 || !hasReusablePublicPaykitInvoice() { + let hasReusableInvoice = await hasReusablePublicPaykitInvoice() + let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice + if shouldRefreshBolt11 { try await refreshPublicPaykitBolt11() } } else { From 3ea3f5832a803b04d8e8ae8ee1f8761ae21e7d90 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 15:49:42 -0500 Subject: [PATCH 21/26] fix: tighten public paykit lifecycle --- Bitkit/Managers/PubkyProfileManager.swift | 16 +++++++++++++++- Bitkit/Utilities/AppReset.swift | 2 +- Bitkit/Views/Contacts/AddContactView.swift | 15 ++++++++++++--- Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift | 4 +++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 552418691..477b8e69f 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -552,19 +552,33 @@ class PubkyProfileManager: ObservableObject { await PubkyImageCache.shared.clear() UserDefaults.standard.removeObject(forKey: cachedNameKey) UserDefaults.standard.removeObject(forKey: cachedImageUriKey) + clearPublicPaykitSharingState() notifyAppStateBackupChanged() } - static func removePublicPaykitEndpointsBestEffort(context: String) async { + private static func clearPublicPaykitSharingState() { + UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints") + UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash") + UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt") + } + + static func removePublicPaykitEndpoints(context: String) async throws { do { try await PublicPaykitService.removePublishedEndpoints() } catch PubkyServiceError.sessionNotActive { Logger.debug("Skipping public Paykit endpoint cleanup because no session is active", context: context) } catch { Logger.warn("Failed to remove public Paykit endpoints before clearing session: \(error)", context: context) + throw error } } + static func removePublicPaykitEndpointsBestEffort(context: String) async { + try? await removePublicPaykitEndpoints(context: context) + } + func signOut() async { await Task.detached { await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 0618b33b9..e844bf33d 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -20,7 +20,7 @@ enum AppReset { await VssBackupClient.shared.reset() VssStoreIdProvider.shared.clearCache() - await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") + try await PubkyProfileManager.removePublicPaykitEndpoints(context: "AppReset.wipe") // Stop node and wipe LDK persistence via the wallet API. try await wallet.wipe() diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index bb551670f..c2922825a 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -16,6 +16,7 @@ struct AddContactView: View { @State private var isLoading = true @State private var isSaving = false @State private var errorMessage: String? + @State private var canRetryError = true private var truncatedPublicKey: String { let displayKey = normalizedPublicKey ?? publicKey @@ -177,10 +178,14 @@ struct AddContactView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) - CustomButton(title: t("common__retry"), variant: .secondary) { - await loadProfile() + CustomButton(title: canRetryError ? t("common__retry") : t("common__discard"), variant: .secondary) { + if canRetryError { + await loadProfile() + } else { + navigation.navigateBack() + } } - .accessibilityIdentifier("AddContactRetry") + .accessibilityIdentifier(canRetryError ? "AddContactRetry" : "AddContactDiscard") Spacer() } @@ -194,6 +199,7 @@ struct AddContactView: View { isLoading = true fetchedProfile = nil errorMessage = nil + canRetryError = true switch resolveAddContactValidation( input: publicKey, @@ -202,14 +208,17 @@ struct AddContactView: View { ) { case .empty, .invalidKey: errorMessage = t("contacts__add_error_invalid_key") + canRetryError = false isLoading = false return case .ownKey: errorMessage = t("contacts__add_error_self") + canRetryError = false isLoading = false return case .existingContact: errorMessage = t("contacts__add_error_existing") + canRetryError = false isLoading = false return case let .valid(normalizedKey): diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift index d4b625661..d6672b271 100644 --- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift +++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift @@ -197,6 +197,7 @@ struct LnurlPayConfirm: View { let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: bolt11) let paymentHash = String(describing: parsedInvoice.paymentHash()) + let contactPublicKey = app.contactPaymentContext?.publicKey do { // Perform the Lightning payment (10s timeout → navigate to pending for hold invoices) @@ -206,10 +207,11 @@ struct LnurlPayConfirm: View { bolt11: bolt11, sats: nil, onTimeout: { - app.addPendingPaymentHash(paymentHash) + app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) navigationPath.append(.pending(paymentHash: paymentHash)) } ) + app.addPendingContactPaymentContext(paymentHash, contactPublicKey: contactPublicKey) Logger.info("LNURL payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) } catch is PaymentTimeoutError { From 5ee3d844e4faaa7510ee4b18f6005d05c14d78df Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 15:57:19 -0500 Subject: [PATCH 22/26] fix: address public paykit cleanup review --- Bitkit/Utilities/AppReset.swift | 4 ++-- Bitkit/ViewModels/WalletViewModel.swift | 4 ++-- Bitkit/Views/Contacts/AddContactView.swift | 20 ++++++++----------- Bitkit/Views/Contacts/ContactDetailView.swift | 20 ++++++++----------- 4 files changed, 20 insertions(+), 28 deletions(-) diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index e844bf33d..f1de19499 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -9,6 +9,8 @@ enum AppReset { session: SessionManager, toastType: Toast.ToastType = .success ) async throws { + try await PubkyProfileManager.removePublicPaykitEndpoints(context: "AppReset.wipe") + // Set wiping flag to prevent backups during wipe operations BackupService.shared.setWiping(true) defer { @@ -20,8 +22,6 @@ enum AppReset { await VssBackupClient.shared.reset() VssStoreIdProvider.shared.clearCache() - try await PubkyProfileManager.removePublicPaykitEndpoints(context: "AppReset.wipe") - // Stop node and wipe LDK persistence via the wallet API. try await wallet.wipe() diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 828a8c325..e62063e0a 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -206,9 +206,9 @@ class WalletViewModel: ObservableObject { paymentHash: paymentHash, shortChannelId: shortChannelId ) - case let .paymentReceived(paymentId, paymentHash, _, _): + case let .paymentReceived(_, paymentHash, _, _): self.bolt11 = "" - self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentId ?? paymentHash) + self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) Task { await self.refreshAndSyncState() try? await self.refreshBip21() diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index c2922825a..4004ad38e 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -289,18 +289,14 @@ struct AddContactView: View { ) return } - case .noEndpoint: - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_empty_msg") - ) - case .notOpened: - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_not_opened_msg") - ) + 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 public pubky \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", context: "AddContactView") diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index 19ba58355..d96b8a281 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -294,18 +294,14 @@ struct ContactDetailView: View { ) return } - case .noEndpoint: - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_empty_msg") - ) - case .notOpened: - app.toast( - type: .warning, - title: t("slashtags__error_pay_title"), - description: t("slashtags__error_pay_not_opened_msg") - ) + 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(publicKey)): \(error)", context: "ContactDetailView") From a41afa5c3460338876af0f26e99f71080701e26f Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 16:11:53 -0500 Subject: [PATCH 23/26] fix: align paykit contacts with android --- Bitkit/Services/PublicPaykitService.swift | 67 ++++++++++++++----- Bitkit/Utilities/AppReset.swift | 2 +- Bitkit/ViewModels/WalletViewModel.swift | 2 +- .../Views/Contacts/ContactActivityView.swift | 2 +- Bitkit/Views/Profile/PayContactsView.swift | 6 +- 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 87723f761..9f383b367 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -36,7 +36,39 @@ enum PublicPaykitPaymentLaunchResult { } } +private actor PublicPaykitEndpointLock { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func withLock(_ operation: () async throws -> T) async throws -> T { + await lock() + defer { unlock() } + return try await operation() + } + + private func lock() async { + if !isLocked { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func unlock() { + guard !waiters.isEmpty else { + isLocked = false + return + } + + waiters.removeFirst().resume() + } +} + enum PublicPaykitService { + private static let endpointLock = PublicPaykitEndpointLock() enum MethodId: String, Hashable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" case bitcoinLightningLnurl = "btc-lightning-lnurl" @@ -88,7 +120,8 @@ enum PublicPaykitService { } static func fetchPublicEndpoints(publicKey: String) async throws -> [Endpoint] { - let paymentEntries = try await PubkyService.getPaymentList(publicKey: publicKey) + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let paymentEntries = try await PubkyService.getPaymentList(publicKey: normalizedKey) var endpointsByMethodId: [MethodId: Endpoint] = [:] for entry in paymentEntries { @@ -152,10 +185,12 @@ enum PublicPaykitService { } static func removePublishedEndpoints() async throws { - let existingMethodIds = try await currentPublishedMethodIds() + try await endpointLock.withLock { + let existingMethodIds = try await currentPublishedMethodIds() - for methodId in methodIdsToRemoveWhenUnpublishing(existingMethodIds: existingMethodIds) { - try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + for methodId in methodIdsToRemoveWhenUnpublishing(existingMethodIds: existingMethodIds) { + try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + } } } @@ -266,18 +301,20 @@ enum PublicPaykitService { } private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { - let existingEndpoints = try await currentPublishedEndpoints() - let plan = publishedEndpointSyncPlan(existingEndpoints: existingEndpoints, desiredEndpoints: desiredEndpoints) - - for endpoint in plan.endpointsToSet { - try await PubkyService.setPaymentEndpoint( - methodId: endpoint.methodId.rawValue, - endpointData: endpoint.rawPayload - ) - } + try await endpointLock.withLock { + let existingEndpoints = try await currentPublishedEndpoints() + let plan = publishedEndpointSyncPlan(existingEndpoints: existingEndpoints, desiredEndpoints: desiredEndpoints) + + for endpoint in plan.endpointsToSet { + try await PubkyService.setPaymentEndpoint( + methodId: endpoint.methodId.rawValue, + endpointData: endpoint.rawPayload + ) + } - for methodId in plan.methodIdsToRemove { - try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + for methodId in plan.methodIdsToRemove { + try await PubkyService.removePaymentEndpoint(methodId: methodId.rawValue) + } } } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index f1de19499..a93fc784b 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -9,7 +9,7 @@ enum AppReset { session: SessionManager, toastType: Toast.ToastType = .success ) async throws { - try await PubkyProfileManager.removePublicPaykitEndpoints(context: "AppReset.wipe") + await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") // Set wiping flag to prevent backups during wipe operations BackupService.shared.setWiping(true) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index e62063e0a..ee91fee61 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -61,7 +61,7 @@ class WalletViewModel: ObservableObject { @AppStorage("legacyNetworkGraphCleanupDone") private var legacyNetworkGraphCleanupDone = false @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false - private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 15 * 60 + private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60 private let lightningService: LightningService private let coreService: CoreService diff --git a/Bitkit/Views/Contacts/ContactActivityView.swift b/Bitkit/Views/Contacts/ContactActivityView.swift index e908c5742..78f47995d 100644 --- a/Bitkit/Views/Contacts/ContactActivityView.swift +++ b/Bitkit/Views/Contacts/ContactActivityView.swift @@ -20,7 +20,7 @@ struct ContactActivityView: View { var body: some View { VStack(spacing: 0) { - NavigationBar(title: t("wallet__activity")) + NavigationBar(title: contactName.isEmpty ? t("wallet__activity") : contactName) .padding(.horizontal, 16) if isLoading { diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 19cb46f5e..7e0889ec0 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -8,7 +8,7 @@ struct PayContactsView: View { @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel - @State private var enablePayments = false + @State private var enablePayments = true @State private var isSaving = false var body: some View { @@ -61,7 +61,7 @@ struct PayContactsView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { - enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : false + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true } } @@ -76,7 +76,7 @@ struct PayContactsView: View { hasConfirmedPublicPaykitEndpoints = true navigation.path = [.profile] } catch { - enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : false + enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true Logger.error("Failed to sync public payment endpoints: \(error)", context: "PayContactsView") app.toast( type: .error, From c58922938b3dece86df5a2d57fedf2c6ffd81f3d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 4 May 2026 21:50:36 -0500 Subject: [PATCH 24/26] chore: update paykit bindings to rc5 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 7ba95d9b4..43c2c04f0 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-rc3; + version = 0.1.0-rc5; }; }; 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 3172920b3..7283543a7 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "e572980d75c848bd73d8e57a9c99cf5b8096d487", - "version" : "0.1.0-rc3" + "revision" : "04759b7b9bca7a0dd8a29e724f11e984db361241", + "version" : "0.1.0-rc5" } }, { From 31b2b28b54b6794570d4631e7404e06c7de9d1e1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 5 May 2026 10:51:35 -0500 Subject: [PATCH 25/26] fix: use network-specific paykit onchain methods --- Bitkit/Services/PublicPaykitService.swift | 115 ++++++++++++++++----- BitkitTests/PublicPaykitServiceTests.swift | 40 ++++--- 2 files changed, 116 insertions(+), 39 deletions(-) diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 9f383b367..dd1b54d5e 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -69,37 +69,96 @@ private actor PublicPaykitEndpointLock { enum PublicPaykitService { private static let endpointLock = PublicPaykitEndpointLock() - enum MethodId: String, Hashable { + enum MethodId: String, Hashable, CaseIterable { case bitcoinLightningBolt11 = "btc-lightning-bolt11" case bitcoinLightningLnurl = "btc-lightning-lnurl" case bitcoinOnchainP2tr = "btc-bitcoin-p2tr" case bitcoinOnchainP2wpkh = "btc-bitcoin-p2wpkh" case bitcoinOnchainP2sh = "btc-bitcoin-p2sh" case bitcoinOnchainP2pkh = "btc-bitcoin-p2pkh" + case testnetOnchainP2tr = "btc-testnet-p2tr" + case testnetOnchainP2wpkh = "btc-testnet-p2wpkh" + case testnetOnchainP2sh = "btc-testnet-p2sh" + case testnetOnchainP2pkh = "btc-testnet-p2pkh" + case signetOnchainP2tr = "btc-signet-p2tr" + case signetOnchainP2wpkh = "btc-signet-p2wpkh" + case signetOnchainP2sh = "btc-signet-p2sh" + case signetOnchainP2pkh = "btc-signet-p2pkh" + case regtestOnchainP2tr = "btc-regtest-p2tr" + case regtestOnchainP2wpkh = "btc-regtest-p2wpkh" + case regtestOnchainP2sh = "btc-regtest-p2sh" + case regtestOnchainP2pkh = "btc-regtest-p2pkh" static let payablePreferenceOrder: [MethodId] = [ .bitcoinLightningBolt11, .bitcoinLightningLnurl, - .bitcoinOnchainP2tr, - .bitcoinOnchainP2wpkh, - .bitcoinOnchainP2sh, - .bitcoinOnchainP2pkh, - ] + ] + onchainPreferenceOrder static let publishableMethodIds: [MethodId] = [ .bitcoinLightningBolt11, - .bitcoinOnchainP2tr, - .bitcoinOnchainP2wpkh, - .bitcoinOnchainP2sh, - .bitcoinOnchainP2pkh, - ] + ] + onchainPreferenceOrder static let onchainPreferenceOrder: [MethodId] = [ .bitcoinOnchainP2tr, + .testnetOnchainP2tr, + .signetOnchainP2tr, + .regtestOnchainP2tr, .bitcoinOnchainP2wpkh, + .testnetOnchainP2wpkh, + .signetOnchainP2wpkh, + .regtestOnchainP2wpkh, .bitcoinOnchainP2sh, + .testnetOnchainP2sh, + .signetOnchainP2sh, + .regtestOnchainP2sh, .bitcoinOnchainP2pkh, + .testnetOnchainP2pkh, + .signetOnchainP2pkh, + .regtestOnchainP2pkh, ] + + var onchainNetwork: LDKNode.Network? { + switch self { + case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh: + .bitcoin + case .testnetOnchainP2tr, .testnetOnchainP2wpkh, .testnetOnchainP2sh, .testnetOnchainP2pkh: + .testnet + case .signetOnchainP2tr, .signetOnchainP2wpkh, .signetOnchainP2sh, .signetOnchainP2pkh: + .signet + case .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh: + .regtest + case .bitcoinLightningBolt11, .bitcoinLightningLnurl: + nil + } + } + + static func onchainMethodId(network: LDKNode.Network, scriptType: OnchainScriptType) -> MethodId { + switch (network, scriptType) { + case (.bitcoin, .p2tr): .bitcoinOnchainP2tr + case (.bitcoin, .p2wpkh): .bitcoinOnchainP2wpkh + case (.bitcoin, .p2sh): .bitcoinOnchainP2sh + case (.bitcoin, .p2pkh): .bitcoinOnchainP2pkh + case (.testnet, .p2tr): .testnetOnchainP2tr + case (.testnet, .p2wpkh): .testnetOnchainP2wpkh + case (.testnet, .p2sh): .testnetOnchainP2sh + case (.testnet, .p2pkh): .testnetOnchainP2pkh + case (.signet, .p2tr): .signetOnchainP2tr + case (.signet, .p2wpkh): .signetOnchainP2wpkh + case (.signet, .p2sh): .signetOnchainP2sh + case (.signet, .p2pkh): .signetOnchainP2pkh + case (.regtest, .p2tr): .regtestOnchainP2tr + case (.regtest, .p2wpkh): .regtestOnchainP2wpkh + case (.regtest, .p2sh): .regtestOnchainP2sh + case (.regtest, .p2pkh): .regtestOnchainP2pkh + } + } + } + + enum OnchainScriptType { + case p2tr + case p2wpkh + case p2sh + case p2pkh } struct Endpoint: Equatable, Hashable { @@ -243,22 +302,21 @@ enum PublicPaykitService { return "bitcoin:\(onchainEndpoint.paymentRequest)?lightning=\(encodedLightning)" } - static func onchainMethodId(for address: String) -> MethodId { + static func onchainMethodId(for address: String, network: LDKNode.Network = Env.network) -> MethodId { let normalizedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - - if normalizedAddress.hasPrefix("bc1p") || normalizedAddress.hasPrefix("tb1p") || normalizedAddress.hasPrefix("bcrt1p") { - return .bitcoinOnchainP2tr - } - - if normalizedAddress.hasPrefix("bc1q") || normalizedAddress.hasPrefix("tb1q") || normalizedAddress.hasPrefix("bcrt1q") { - return .bitcoinOnchainP2wpkh - } - - if normalizedAddress.hasPrefix("3") || normalizedAddress.hasPrefix("2") { - return .bitcoinOnchainP2sh + let scriptType: OnchainScriptType = if normalizedAddress.hasPrefix("bc1p") || normalizedAddress.hasPrefix("tb1p") || normalizedAddress + .hasPrefix("bcrt1p") + { + .p2tr + } else if normalizedAddress.hasPrefix("bc1q") || normalizedAddress.hasPrefix("tb1q") || normalizedAddress.hasPrefix("bcrt1q") { + .p2wpkh + } else if normalizedAddress.hasPrefix("3") || normalizedAddress.hasPrefix("2") { + .p2sh + } else { + .p2pkh } - return .bitcoinOnchainP2pkh + return MethodId.onchainMethodId(network: network, scriptType: scriptType) } static func methodIdsToRemoveWhenUnpublishing(existingMethodIds: Set) -> [MethodId] { @@ -410,7 +468,14 @@ enum PublicPaykitService { return true - case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh: + case .bitcoinOnchainP2tr, .bitcoinOnchainP2wpkh, .bitcoinOnchainP2sh, .bitcoinOnchainP2pkh, + .testnetOnchainP2tr, .testnetOnchainP2wpkh, .testnetOnchainP2sh, .testnetOnchainP2pkh, + .signetOnchainP2tr, .signetOnchainP2wpkh, .signetOnchainP2sh, .signetOnchainP2pkh, + .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh: + guard endpoint.methodId.onchainNetwork == Env.network else { + return false + } + guard case let .onChain(invoice) = try? await decode(invoice: endpoint.paymentRequest) else { return false } diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index e4be4e8ba..f4593ae2f 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -1,5 +1,6 @@ @testable import Bitkit import Foundation +import LDKNode import XCTest final class PublicPaykitServiceTests: XCTestCase { @@ -43,6 +44,23 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(endpoint?.value, "lnurl1example") } + func testParseEndpointReadsNetworkSpecificOnchainMethodIds() { + XCTAssertEqual( + PublicPaykitService.parseEndpoint( + methodId: "btc-testnet-p2wpkh", + endpointData: #"{"value":"tb1qexample"}"# + )?.methodId, + .testnetOnchainP2wpkh + ) + XCTAssertEqual( + PublicPaykitService.parseEndpoint( + methodId: "btc-regtest-p2tr", + endpointData: #"{"value":"bcrt1pexample"}"# + )?.methodId, + .regtestOnchainP2tr + ) + } + func testParseEndpointRejectsNonSpecLegacyLnurlMethodId() { let endpoint = PublicPaykitService.parseEndpoint( methodId: "btc-lightning-lnurl-pay", @@ -54,16 +72,8 @@ final class PublicPaykitServiceTests: XCTestCase { func testKnownMethodIdsFollowPaymentEndpointIdentifierSpec() { let specPattern = #"^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$"# - let methodIds: [PublicPaykitService.MethodId] = [ - .bitcoinLightningBolt11, - .bitcoinLightningLnurl, - .bitcoinOnchainP2tr, - .bitcoinOnchainP2wpkh, - .bitcoinOnchainP2sh, - .bitcoinOnchainP2pkh, - ] - for methodId in methodIds { + for methodId in PublicPaykitService.MethodId.allCases { XCTAssertNotNil(methodId.rawValue.range(of: specPattern, options: .regularExpression), "\(methodId.rawValue) must be asset-rail-endpoint") } } @@ -122,11 +132,13 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual(request, "lnurl1example") } - func testOnchainMethodIdUsesAddressPrefix() { - XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample"), .bitcoinOnchainP2tr) - XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample"), .bitcoinOnchainP2wpkh) - XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "3Example"), .bitcoinOnchainP2sh) - XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "1Example"), .bitcoinOnchainP2pkh) + func testOnchainMethodIdUsesAddressPrefixAndNetwork() { + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bc1pexample", network: .bitcoin), .bitcoinOnchainP2tr) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "tb1qexample", network: .testnet), .testnetOnchainP2wpkh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "bcrt1qexample", network: .regtest), .regtestOnchainP2wpkh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "3Example", network: .bitcoin), .bitcoinOnchainP2sh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "2Example", network: .regtest), .regtestOnchainP2sh) + XCTAssertEqual(PublicPaykitService.onchainMethodId(for: "1Example", network: .bitcoin), .bitcoinOnchainP2pkh) } func testPaymentLaunchResultFailureMessageKeys() { From 28815378a3be0e73712c5f21f9804a1fad60171a Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 5 May 2026 12:51:10 -0500 Subject: [PATCH 26/26] fix: address paykit review feedback --- Bitkit/Managers/ScannerManager.swift | 5 +++++ Bitkit/Utilities/PaymentNavigationHelper.swift | 4 ++-- Bitkit/ViewModels/ActivityListViewModel.swift | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index f5e4ef842..5b057e4cf 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -54,6 +54,11 @@ class ScannerManager: ObservableObject { private func handleAddContactScan(_ input: String) { navigation?.navigateBack() + + guard !handlePubkyRouteIfNeeded(input) else { + return + } + navigation?.navigate(.addContact(publicKey: input)) } diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index 46ddf9953..a9c30fd11 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -158,8 +158,8 @@ struct PaymentNavigationHelper { switch route { case .quickpay: - if app.lnurlPayData != nil { - return .lnurlPayAmount + if let lnurlPayData = app.lnurlPayData { + return lnurlPayData.isFixedAmount ? .lnurlPayConfirm : .lnurlPayAmount } if let invoice = app.scannedLightningInvoice { diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index a7ab39536..944c44733 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -273,7 +273,8 @@ class ActivityListViewModel: ObservableObject { } func contactActivities(publicKey: String) async throws -> [Activity] { - try await coreService.activity.get(contact: publicKey, sortDirection: .desc) + let activities = try await coreService.activity.get(contact: publicKey, sortDirection: .desc) + return await filterOutReplacedSentTransactions(activities) } func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws {