diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index df861a50..ed3072b5 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -907,7 +907,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = 0.1.0-rc5; + version = "0.1.0-rc5"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { @@ -930,8 +930,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { - kind = revision; - revision = 52f73c4402cfb06a020ab8fa9594b5ecb94e3cd6; + kind = exactVersion; + version = "0.7.0-rc.39"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7283543a..920344ed 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d158db056599c21ce7702af0c74aa95296da8e9b08fcbc00728f449ce4872dde", + "originHash" : "746cacd44d171f1c992b592ea1224d0562117bffd2907a54dee9d441395ac6b4", "pins" : [ { "identity" : "bitkit-core", @@ -24,7 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "52f73c4402cfb06a020ab8fa9594b5ecb94e3cd6" + "revision" : "c3593aebb7efe2605c08e40fee7a72d382d44401", + "version" : "0.7.0-rc.39" } }, { diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 90a00559..c6086e98 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -77,6 +77,15 @@ struct AppScene: View { _settings = StateObject(wrappedValue: SettingsViewModel.shared) _transferTracking = StateObject(wrappedValue: TransferTrackingManager(service: transferService)) + + CoreService.shared.activity.setPrivatePaykitContactResolvers( + invoice: { paymentHash in + await PrivatePaykitService.shared.contactPublicKey(forPrivateInvoicePaymentHash: paymentHash) + }, + onchainAddress: { address in + await PrivatePaykitAddressReservationStore.shared.contactPublicKey(forReservedAddress: address) + } + ) } var body: some View { @@ -91,7 +100,7 @@ struct AppScene: View { .onChange(of: currency.hasStaleData) { _, newValue in handleCurrencyStaleData(newValue) } .onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) } .onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) } - .onChange(of: scenePhase) { _, newValue in handleScenePhaseChange(newValue) } + .onChange(of: scenePhase, initial: true) { _, newValue in handleScenePhaseChange(newValue) } .onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) } .onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in if !isLoading { @@ -142,6 +151,13 @@ struct AppScene: View { contactsManager.reset() } } + .onReceive(contactsManager.$contacts) { contacts in + guard wallet.walletExists == true, pubkyProfile.authState == .authenticated else { return } + let publicKeys = contacts.map(\.publicKey) + Task { + await PrivatePaykitService.shared.prepareSavedContacts(publicKeys, wallet: wallet) + } + } .onChange(of: navigation.currentRoute) { oldRoute, newRoute in guard shouldDiscardPendingImport(currentRoute: oldRoute, destination: newRoute) else { return @@ -527,6 +543,13 @@ struct AppScene: View { walletInitShouldFinish = true app.markAppStatusInit() BackupService.shared.startObservingBackups() + Task { + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) + } } else { if case .errorStarting = state { walletInitShouldFinish = true @@ -552,7 +575,16 @@ struct AppScene: View { Task { await clearDeliveredNotifications() await LightningService.shared.reconnectPeers() + try? await wallet.sync() + await PrivatePaykitService.shared.retryPendingEndpointRemoval( + wallet: wallet, + savedPublicKeys: contactsManager.contacts.map(\.publicKey) + ) await wallet.refreshPublicPaykitEndpointsOnForeground() + await PrivatePaykitService.shared.refreshSavedContactEndpoints( + for: contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) } } } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 845b69e0..e5b2fe8a 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -73,7 +73,7 @@ enum Env { /// Returns the keychain access group based on the current network static var keychainGroup: String { let base = "KYH47R284B.to.bitkit" - let networkSuffix = networkName(network) + let networkSuffix = networkName return networkSuffix == "bitcoin" ? base : "\(base).\(networkSuffix)" } @@ -106,8 +106,13 @@ enum Env { } } - /// Returns the lowercase name of the network (e.g., "bitcoin", "testnet", "signet", "regtest") - private static func networkName(_ network: LDKNode.Network) -> String { + /// Lowercase storage/display name for the current network. + static var networkName: String { + networkName(for: network) + } + + /// Lowercase storage/display name for a network. + static func networkName(for network: LDKNode.Network) -> String { switch network { case .bitcoin: "bitcoin" case .testnet: "testnet" @@ -150,13 +155,13 @@ enum Env { static func ldkStorage(walletIndex: Int) -> URL { appStorageUrl - .appendingPathComponent(networkName(network)) + .appendingPathComponent(networkName) .appendingPathComponent("wallet\(walletIndex)/ldk") } static func bitkitCoreStorage(walletIndex: Int) -> URL { appStorageUrl - .appendingPathComponent(networkName(network)) + .appendingPathComponent(networkName) .appendingPathComponent("wallet\(walletIndex)/core") } @@ -242,7 +247,7 @@ enum Env { ] static var vssStoreIdPrefix: String { - "bitkit_v1_\(networkName(network))" + "bitkit_v1_\(networkName)" } static var vssServerUrl: String { diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index a8c47860..388b6d35 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -77,6 +77,7 @@ enum ContactsManagerError: LocalizedError { // MARK: - PubkyContact +// swiftformat:disable:next redundantSendable struct PubkyContact: Identifiable, Hashable, Sendable { let id: String let publicKey: String @@ -173,6 +174,10 @@ class ContactsManager: ObservableObject { let contactPaths = try await Task.detached { try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: basePath) }.value + let savedContactKeys = contactPaths + .map(extractPublicKey(from:)) + .filter { !$0.isEmpty } + .map(ensurePubkyPrefix) Logger.debug("Listed \(contactPaths.count) contacts from homeserver", context: "ContactsManager") @@ -226,6 +231,7 @@ class ContactsManager: ObservableObject { if !contactPaths.isEmpty, loadedResult.contacts.isEmpty { if loadedResult.failures == loadedResult.missingFailures { + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts = [] hasLoaded = true Logger.info("Contacts storage entries were missing, treating list as empty", context: "ContactsManager") @@ -235,6 +241,7 @@ class ContactsManager: ObservableObject { } contacts = loadedResult.contacts.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: savedContactKeys) hasLoaded = true if loadedResult.failures > 0 { @@ -247,6 +254,7 @@ class ContactsManager: ObservableObject { Logger.info("Loaded \(contacts.count) contacts", context: "ContactsManager") } catch { if Self.isMissingContactsDataError(error) { + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts = [] hasLoaded = true loadErrorMessage = nil @@ -403,6 +411,7 @@ class ContactsManager: ObservableObject { Logger.info("Removed contact \(PubkyPublicKeyFormat.redacted(prefixedKey))", context: "ContactsManager") contacts.removeAll { $0.publicKey == prefixedKey } + await PrivatePaykitService.shared.removeSavedContact(publicKey: prefixedKey) } /// Delete all contacts from the homeserver and keep local state in sync with completed deletions. @@ -418,6 +427,7 @@ class ContactsManager: ObservableObject { }.value } catch { if Self.isMissingContactsDataError(error) { + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() return } @@ -448,11 +458,13 @@ class ContactsManager: ObservableObject { if let firstError { if !deletedKeys.isEmpty { contacts.removeAll { deletedKeys.contains($0.publicKey) } + await PrivatePaykitService.shared.removeSavedContacts(publicKeys: Array(deletedKeys)) } throw firstError } // All remote deletes succeeded, so clear any local-only contacts too. + await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) contacts.removeAll() Logger.info("Deleted all contacts", context: "ContactsManager") } diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index d896cd9b..8b111fe5 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -753,6 +753,8 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { + await PrivatePaykitService.shared.closeAndClear() + await PrivatePaykitAddressReservationStore.shared.clearContactAssignments() await PubkyService.forceSignOut() try? Keychain.delete(key: .paykitSession) try? Keychain.delete(key: .pubkySecretKey) @@ -766,6 +768,7 @@ class PubkyProfileManager: ObservableObject { private static func clearPublicPaykitSharingState() { UserDefaults.standard.set(false, forKey: "sharesPublicPaykitEndpoints") UserDefaults.standard.set(false, forKey: "hasConfirmedPublicPaykitEndpoints") + PrivatePaykitService.setContactSharingCleanupPending(false) UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11") UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11PaymentHash") UserDefaults.standard.removeObject(forKey: "publicPaykitBolt11ExpiresAt") @@ -786,9 +789,18 @@ class PubkyProfileManager: ObservableObject { try? await removePublicPaykitEndpoints(context: context) } + static func removePrivatePaykitEndpointsBestEffort(context: String) async { + do { + try await PrivatePaykitService.shared.removePublishedEndpoints() + } catch { + Logger.warn("Failed to remove private Paykit endpoints before clearing session: \(error)", context: context) + } + } + func signOut() async { await Task.detached { await Self.removePublicPaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") + await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.signOut") do { try await PubkyService.signOut() } catch { diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index b0c68c41..90a2bf59 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -7,6 +7,20 @@ struct WalletBackupV1: Codable { let version: Int let createdAt: UInt64 let transfers: [Transfer] + let privatePaykitHighestReservedReceiveIndexByAddressType: [String: UInt32]? + let privatePaykitContactLinks: [String: PrivatePaykitContactLinkBackupV1]? +} + +struct PrivatePaykitContactLinkBackupV1: Codable, Equatable { + let publicKey: String + let linkSnapshotHex: String? + let handshakeSnapshotHex: String? + let remoteEndpoints: [String: String] + let linkCompletedAt: UInt64? + let handshakeUpdatedAt: UInt64? + let recoveryStartedAt: UInt64? + let mainRecoveryAttemptId: String? + let responderRecoveryAttemptId: String? } struct MetadataBackupV1: Codable { diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index b3ccffc9..5a8c0c38 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -203,6 +203,9 @@ class BackupService { try await performRestore(category: .wallet) { dataBytes in let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) try TransferStorage.shared.upsertList(payload.transfers) + await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) + await PrivatePaykitService.shared.restoreBackup(payload.privatePaykitContactLinks) + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() Logger.debug("Restored \(payload.transfers.count) transfers", context: "BackupService") } @@ -321,6 +324,23 @@ class BackupService { } .store(in: &cancellables) + // PRIVATE PAYKIT WALLET DATA + PrivatePaykitAddressReservationStore.walletBackupDataChangedPublisher + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .wallet) + } + .store(in: &cancellables) + + PrivatePaykitService.walletBackupDataChangedPublisher + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, !self.shouldSkipBackup() else { return } + markBackupRequired(category: .wallet) + } + .store(in: &cancellables) + // ACTIVITIES CoreService.shared.activity.activitiesChangedPublisher .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) @@ -374,7 +394,7 @@ class BackupService { } .store(in: &cancellables) - Logger.debug("Started 7 data store listeners", context: "BackupService") + Logger.debug("Started 9 data store listeners", context: "BackupService") } private func startPeriodicBackupFailureCheck() { @@ -653,10 +673,14 @@ class BackupService { case .wallet: let transfers = try TransferStorage.shared.getAll() + let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot() + let privatePaykitContactLinks = await PrivatePaykitService.shared.backupSnapshot() let payload = WalletBackupV1( version: 1, createdAt: UInt64(Date().timeIntervalSince1970 * 1000), - transfers: transfers + transfers: transfers, + privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType, + privatePaykitContactLinks: privatePaykitContactLinks ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index c740d946..cb6d5aad 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -20,6 +20,17 @@ class ActivityService { metadataChangedSubject.eraseToAnyPublisher() } + private var privateInvoiceContactResolver: (@Sendable (String) async -> String?)? + private var privateOnchainAddressContactResolver: (@Sendable (String) async -> String?)? + + func setPrivatePaykitContactResolvers( + invoice: (@Sendable (String) async -> String?)?, + onchainAddress: (@Sendable (String) async -> String?)? + ) { + privateInvoiceContactResolver = invoice + privateOnchainAddressContactResolver = onchainAddress + } + // MARK: - Constants private let addressSearchCoordinator: AddressSearchCoordinator @@ -249,7 +260,7 @@ class ActivityService { } func isReceivedTransaction(txid: String) async -> Bool { - guard let payments = LightningService.shared.payments, + guard let payments = await LightningService.shared.listPayments(), let payment = payments.first(where: { payment in if case let .onchain(paymentTxid, _) = payment.kind { return paymentTxid == txid @@ -284,7 +295,7 @@ class ActivityService { init(coreService: CoreService) { self.coreService = coreService - addressSearchCoordinator = AddressSearchCoordinator(coreService: coreService) + addressSearchCoordinator = AddressSearchCoordinator() } func removeAll() async throws { @@ -364,8 +375,9 @@ class ActivityService { if let existingActivity, case let .onchain(existing) = existingActivity { let existingUpdatedAt = existing.updatedAt ?? 0 let confirmationStatusChanging = existing.confirmed != ldkConfirmed + let needsPrivateContactAttribution = existing.contact == nil && payment.direction == .inbound - if existingUpdatedAt > paymentTimestamp && !confirmationStatusChanging { + if existingUpdatedAt > paymentTimestamp && !confirmationStatusChanging && !needsPrivateContactAttribution { return } } @@ -395,7 +407,7 @@ class ActivityService { var isTransfer = existingOnchain?.isTransfer ?? false var channelId = existingOnchain?.channelId let transferTxId = existingOnchain?.transferTxId - let contact = existingOnchain?.contact + var contact = existingOnchain?.contact let feeRate = existingOnchain?.feeRate ?? 1 let preservedAddress = existingOnchain?.address ?? "Loading..." let doesExist = existingOnchain?.doesExist ?? true @@ -436,6 +448,10 @@ class ActivityService { } catch { Logger.error("Failed to find address for txid \(txid): \(error)", context: "CoreService.processOnchainPayment") } + + if contact == nil { + contact = await privatePaykitContactPublicKey(forReservedAddress: address) + } } // Build and save the activity @@ -483,7 +499,7 @@ class ActivityService { // MARK: - Onchain Event Handlers private func processOnchainTransaction(txid: String, details: BitkitCore.TransactionDetails, context: String) async throws { - guard let payments = LightningService.shared.payments else { + guard let payments = await LightningService.shared.listPayments() else { Logger.warn("No payments available for transaction \(txid)", context: context) return } @@ -552,7 +568,7 @@ class ActivityService { var replacementActivity = try? await self.getOnchainActivityByTxId(txid: conflictTxid) if replacementActivity == nil, - let payments = LightningService.shared.payments, + let payments = await LightningService.shared.listPayments(), let replacementPayment = payments.first(where: { payment in if case let .onchain(paymentTxid, _) = payment.kind { return paymentTxid == conflictTxid @@ -645,12 +661,12 @@ class ActivityService { /// Handle a single payment event by processing the specific payment func handlePaymentEvent(paymentHash: String) async throws { - try await ServiceQueue.background(.core) { - guard let payments = LightningService.shared.payments else { - Logger.warn("No payments available for hash \(paymentHash)", context: "CoreService.handlePaymentEvent") - return - } + guard let payments = await LightningService.shared.listPayments() else { + Logger.warn("No payments available for hash \(paymentHash)", context: "CoreService.handlePaymentEvent") + return + } + try await ServiceQueue.background(.core) { if let payment = payments.first(where: { $0.id == paymentHash }) { try await self.processLightningPayment(payment) } else { @@ -679,11 +695,21 @@ class ActivityService { // Skip if existing activity has newer timestamp, unless payment status is changing if let existing = existingLightning, let existingUpdatedAt = existing.updatedAt { let statusChanging = existing.status != state - if existingUpdatedAt > paymentTimestamp && !statusChanging { + let needsPrivateContactAttribution = existing.contact == nil && payment.direction == .inbound + if existingUpdatedAt > paymentTimestamp && !statusChanging && !needsPrivateContactAttribution { return } } + let contact = if let existingContact = existingLightning?.contact { + existingContact + } else { + await privatePaykitContactPublicKey( + forReceivedInvoicePaymentHash: payment.id, + direction: payment.direction + ) + } + let ln = LightningActivity( id: payment.id, txType: payment.direction == .outbound ? .sent : .received, @@ -694,7 +720,7 @@ class ActivityService { message: description ?? "", timestamp: paymentTimestamp, preimage: preimage, - contact: existingLightning?.contact, + contact: contact, createdAt: paymentTimestamp, updatedAt: paymentTimestamp, seenAt: existingLightning?.seenAt @@ -707,6 +733,15 @@ class ActivityService { } } + private func privatePaykitContactPublicKey(forReceivedInvoicePaymentHash paymentHash: String, direction: PaymentDirection) async -> String? { + guard direction == .inbound else { return nil } + return await privateInvoiceContactResolver?(paymentHash) + } + + private func privatePaykitContactPublicKey(forReservedAddress address: String) async -> String? { + await privateOnchainAddressContactResolver?(address) + } + /// Sync all LDK node payments to activities /// Use for initial wallet load, manual refresh, or after operations that create new payments. /// Events handle individual payment updates, so this should not be called on every event. @@ -1356,14 +1391,9 @@ class ActivityService { // MARK: - Address search (actor for single-flight concurrency) private actor AddressSearchCoordinator { - private let coreService: CoreService private var isSearching = false private var waitQueue: [CheckedContinuation] = [] - init(coreService: CoreService) { - self.coreService = coreService - } - /// Runs the batch address search at most one at a time. Enqueues if a search is already in progress. func runAddressSearch( details: BitkitCore.TransactionDetails, @@ -1414,23 +1444,35 @@ private actor AddressSearchCoordinator { let addressTypesToSearch = LDKNode.AddressType.prioritized(selected: selectedAddressType) - for isChange in [false, true] { + let keychains: [(isChange: Bool, keychain: LDKNode.KeychainKind)] = [ + (false, .external), + (true, .internal), + ] + + for (isChange, keychain) in keychains { for addressType in addressTypesToSearch { let key = isChange ? "addressSearch_lastUsedChangeIndex_\(addressType.stringValue)" : "addressSearch_lastUsedReceiveIndex_\(addressType.stringValue)" - let lastUsed: UInt32? = (UserDefaults.standard.object(forKey: key) as? Int).flatMap { $0 >= 0 ? UInt32($0) : nil } - let endIndex = lastUsed.map { $0 + searchWindow } ?? searchWindow + let lastUsed: UInt32? = (UserDefaults.standard.object(forKey: key) as? Int).flatMap { + guard $0 >= 0, $0 <= Int(UInt32.max) else { return nil } + return UInt32($0) + } + let endIndex = lastUsed.map { $0 > UInt32.max - searchWindow ? UInt32.max : $0 + searchWindow } ?? searchWindow var index: UInt32 = 0 var currentAddressBatch: UInt32? while index < endIndex { - let accountAddresses = try await coreService.utility.getAccountAddresses( - walletIndex: 0, - isChange: isChange, - startIndex: index, - count: batchSize, - addressTypeString: addressType.stringValue - ) - let addresses = accountAddresses.unused.map(\.address) + accountAddresses.used.map(\.address) + let addresses: [String] + do { + addresses = try await LightningService.shared + .addressInfosForType(addressType, keychain: keychain, startIndex: index, count: batchSize) + .map(\.address) + } catch { + Logger.warn( + "Skipping \(addressType.stringValue) \(isChange ? "change" : "receive") address search batch \(index): \(error)", + context: "CoreService.AddressSearch" + ) + break + } if !currentWalletAddress.isEmpty, currentAddressBatch == nil, addresses.contains(currentWalletAddress) { currentAddressBatch = index @@ -1439,8 +1481,10 @@ private actor AddressSearchCoordinator { UserDefaults.standard.set(Int(index), forKey: key) return match } - if let found = currentAddressBatch, index >= found + batchSize { break } - if addresses.count < Int(batchSize) { break } + if let found = currentAddressBatch { + let stopIndex = found > UInt32.max - batchSize ? UInt32.max : found + batchSize + if index >= stopIndex { break } + } index += batchSize } } diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 76d1d47a..07b27225 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -490,12 +490,65 @@ class LightningService { } func newAddressForType(_ addressType: LDKNode.AddressType) async throws -> String { + let addressInfo = try await newAddressInfoForType(addressType) + return addressInfo.address + } + + struct AddressDerivationInfo { + let address: String + let index: UInt32 + } + + func newAddressInfoForType(_ addressType: LDKNode.AddressType) async throws -> AddressDerivationInfo { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let addressInfo = try node.onchainPayment().newAddressInfoForType(addressType: addressType) + return AddressDerivationInfo(address: addressInfo.address, index: addressInfo.index) + } + } + + func addressInfoForType( + _ addressType: LDKNode.AddressType, + keychain: LDKNode.KeychainKind = .external, + atIndex index: UInt32 + ) async throws -> AddressDerivationInfo { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let addressInfo = try node.onchainPayment().addressInfoForTypeAtIndex(addressType: addressType, keychain: keychain, index: index) + return AddressDerivationInfo(address: addressInfo.address, index: addressInfo.index) + } + } + + func addressInfosForType( + _ addressType: LDKNode.AddressType, + keychain: LDKNode.KeychainKind, + startIndex: UInt32, + count: UInt32 + ) async throws -> [AddressDerivationInfo] { guard let node else { throw AppError(serviceError: .nodeNotSetup) } return try await ServiceQueue.background(.ldk) { - try node.onchainPayment().newAddressForType(addressType: addressType) + try node.onchainPayment() + .addressInfosForType(addressType: addressType, keychain: keychain, startIndex: startIndex, count: count) + .map { AddressDerivationInfo(address: $0.address, index: $0.index) } + } + } + + func revealReceiveAddresses(to receiveIndex: UInt32, forType addressType: LDKNode.AddressType) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + try await ServiceQueue.background(.ldk) { + try node.onchainPayment().revealReceiveAddressesTo(addressType: addressType, index: receiveIndex) } } @@ -939,8 +992,10 @@ extension LightningService { cachedChannels } - var payments: [PaymentDetails]? { - node?.listPayments() + func listPayments() async -> [PaymentDetails]? { + try? await ServiceQueue.background(.ldk) { [self] in + node?.listPayments() + } } /// Refresh all cached values asynchronously diff --git a/Bitkit/Services/PrivatePaykitAddressReservationStore.swift b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift new file mode 100644 index 00000000..b1762d52 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitAddressReservationStore.swift @@ -0,0 +1,479 @@ +import Combine +import Foundation +import LDKNode + +actor PrivatePaykitAddressReservationStore { + static let shared = PrivatePaykitAddressReservationStore() + + private static let walletBackupDataChangedSubject = PassthroughSubject() + + nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { + walletBackupDataChangedSubject.eraseToAnyPublisher() + } + + private static let defaultsKey = "privatePaykitAddressReservations" + private static let schemaVersion = 1 + + private let defaults: UserDefaults + private var ledger: Ledger + + // MARK: - Initialization + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + + if let data = defaults.data(forKey: Self.defaultsKey), + let decoded = Self.decodeStoredLedger(data) + { + ledger = decoded + } else { + ledger = .empty + } + } + + // MARK: - Backup + + func backupSnapshot() -> [String: UInt32]? { + let highestReservedReceiveIndexByAddressType = highestReservedReceiveIndexByAddressType() + guard !highestReservedReceiveIndexByAddressType.isEmpty else { + return nil + } + + return highestReservedReceiveIndexByAddressType + } + + func restoreBackup(_ highestReservedReceiveIndexByAddressType: [String: UInt32]?) { + guard let highestReservedReceiveIndexByAddressType else { + ledger = .empty + persist() + return + } + + ledger = Ledger( + version: Self.schemaVersion, + reservedReceiveIndexesByAddressType: [:], + contactAssignments: [:], + contactAssignmentHistory: [:], + restoredReservedReceiveIndexCeilingsByAddressType: highestReservedReceiveIndexByAddressType + ) + persist() + + if !highestReservedReceiveIndexByAddressType.isEmpty { + UserDefaults.standard.removeObject(forKey: "onchainAddress") + } + } + + private static func decodeStoredLedger(_ data: Data) -> Ledger? { + try? JSONDecoder().decode(Ledger.self, from: data) + } + + // MARK: - Contact Assignments + + func hasContactAssignment(for publicKey: String) -> Bool { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + let assignment = ledger.contactAssignments[normalizedKey] + else { return false } + + return LDKNode.AddressType.from(string: assignment.addressType) != nil + } + + func contactPublicKey(forReservedAddress address: String) async -> String? { + guard !address.isEmpty else { return nil } + + if let publicKey = await currentContactPublicKey(forReservedAddress: address) { + return publicKey + } + + for (publicKey, history) in ledger.contactAssignmentHistory { + for assignment in history { + guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), + addressType.matchesAddressFormat(address, network: Env.network), + let reservedAddress = try? await self.address(for: addressType, receiveIndex: assignment.receiveIndex), + reservedAddress == address + else { continue } + + return publicKey + } + } + + return nil + } + + func currentContactPublicKey(forReservedAddress address: String) async -> String? { + guard !address.isEmpty else { return nil } + + for (publicKey, assignment) in ledger.contactAssignments { + guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), + addressType.matchesAddressFormat(address, network: Env.network), + let reservedAddress = try? await self.address(for: addressType, receiveIndex: assignment.receiveIndex), + reservedAddress == address + else { continue } + + return publicKey + } + + return nil + } + + func currentOrRotatedAddress(for publicKey: String) async throws -> String { + if let existing = try await reservedAddressDetails(for: publicKey) { + guard isAddressTypeMonitored(existing.addressType) else { + clearCurrentContactAssignment(publicKey: publicKey) + return try await allocateAddress(for: publicKey) + } + + do { + let isUsed = try await CoreService.shared.utility.isAddressUsed(address: existing.address) + if !isUsed { + return existing.address + } + } catch { + Logger.warn( + "Unable to verify private Paykit address usage; skipping private address publication: \(error)", + context: "PrivatePaykit" + ) + throw error + } + } + + return try await allocateAddress(for: publicKey) + } + + func rotateAddress(for publicKey: String) async throws -> String { + return try await allocateAddress(for: publicKey) + } + + private func reservedAddressDetails(for publicKey: String) async throws -> (address: String, addressType: LDKNode.AddressType)? { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + let assignment = ledger.contactAssignments[normalizedKey], + let addressType = LDKNode.AddressType.from(string: assignment.addressType) + else { return nil } + + let address = try await address(for: addressType, receiveIndex: assignment.receiveIndex) + return (address: address, addressType: addressType) + } + + private func highestReservedReceiveIndexByAddressType() -> [String: UInt32] { + var highest = ledger.reservedReceiveIndexesByAddressType.compactMapValues { $0.max() } + for (addressType, ceiling) in ledger.restoredReservedReceiveIndexCeilingsByAddressType { + highest[addressType] = max(highest[addressType] ?? 0, ceiling) + } + return highest + } + + private func contactAssignmentsForAttribution() -> [(publicKey: String, assignment: StoredAssignment)] { + var seenAssignmentKeys = Set() + var assignments: [(publicKey: String, assignment: StoredAssignment)] = [] + + for (publicKey, assignment) in ledger.contactAssignments { + let key = Self.assignmentKey(assignment) + guard seenAssignmentKeys.insert(key).inserted else { continue } + assignments.append((publicKey: publicKey, assignment: assignment)) + } + + for (publicKey, history) in ledger.contactAssignmentHistory { + for assignment in history { + let key = Self.assignmentKey(assignment) + guard seenAssignmentKeys.insert(key).inserted else { continue } + assignments.append((publicKey: publicKey, assignment: assignment)) + } + } + + return assignments + } + + // MARK: - Rotation Detection + + func contactsWithUsedReservedAddresses() async -> [String] { + var publicKeys: [String] = [] + + for (publicKey, assignment) in ledger.contactAssignments { + guard let addressType = LDKNode.AddressType.from(string: assignment.addressType), + let address = try? await address(for: addressType, receiveIndex: assignment.receiveIndex) + else { continue } + + do { + if try await CoreService.shared.utility.isAddressUsed(address: address) { + publicKeys.append(publicKey) + } + } catch { + Logger.warn( + "Unable to check private Paykit reserved address usage for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + + return publicKeys + } + + // MARK: - Reusable Receive Protection + + func reconcileReservedIndexesWithLdk() async { + for (addressTypeString, highestReserved) in highestReservedReceiveIndexByAddressType() { + guard let addressType = LDKNode.AddressType.from(string: addressTypeString) else { continue } + guard isAddressTypeMonitored(addressType) else { continue } + + do { + try await reconcileAddressTypeWithLdk(addressType, highestReserved: highestReserved) + } catch { + Logger.warn("Failed to reconcile private Paykit address reservations: \(error)", context: "PrivatePaykit") + } + } + + await clearReusableOnchainAddressIfReserved() + } + + func nextNonReservedReceiveAddress(addressType: LDKNode.AddressType) async throws -> String { + try await prepareReusableReceive(addressType: addressType) + + let addressInfo = try await LightningService.shared.newAddressInfoForType(addressType) + guard !isUnavailableForReusableReceive(receiveIndex: addressInfo.index, addressType: addressType) else { + throw AppError( + message: "Unable to generate receive address", + debugMessage: "LDK returned unavailable receive index \(addressInfo.index) after reservation reconciliation" + ) + } + + return addressInfo.address + } + + func isUnavailableForReusableReceive(address: String, addressType: LDKNode.AddressType) async -> Bool { + guard !address.isEmpty, + addressType.matchesAddressFormat(address, network: Env.network) + else { return false } + + let addressTypeKey = addressType.stringValue + for receiveIndex in ledger.reservedReceiveIndexesByAddressType[addressTypeKey] ?? [] { + guard let reservedAddress = try? await self.address(for: addressType, receiveIndex: receiveIndex), + reservedAddress == address + else { continue } + + return true + } + + return false + } + + func isUnavailableForReusableReceive(address: String) async -> Bool { + guard !address.isEmpty else { return false } + + for addressTypeString in highestReservedReceiveIndexByAddressType().keys { + guard let addressType = LDKNode.AddressType.from(string: addressTypeString), + await isUnavailableForReusableReceive(address: address, addressType: addressType) + else { continue } + + return true + } + + return false + } + + private func prepareReusableReceive(addressType: LDKNode.AddressType) async throws { + if let highestReserved = highestReservedReceiveIndexByAddressType()[addressType.stringValue] { + try await reconcileAddressTypeWithLdk(addressType, highestReserved: highestReserved) + } + + await clearReusableOnchainAddressIfReserved() + } + + private func isUnavailableForReusableReceive(receiveIndex: UInt32, addressType: LDKNode.AddressType) -> Bool { + let addressTypeKey = addressType.stringValue + let reservedIndexes = ledger.reservedReceiveIndexesByAddressType[addressTypeKey] ?? [] + if reservedIndexes.contains(receiveIndex) { + return true + } + + if let restoredCeiling = ledger.restoredReservedReceiveIndexCeilingsByAddressType[addressTypeKey], + receiveIndex <= restoredCeiling + { + return true + } + + return false + } + + // MARK: - Cleanup + + func clear() { + ledger = .empty + defaults.removeObject(forKey: Self.defaultsKey) + markWalletBackupDataChanged() + } + + func clearContactAssignments() { + guard !ledger.contactAssignments.isEmpty || !ledger.contactAssignmentHistory.isEmpty else { return } + ledger.contactAssignments = [:] + ledger.contactAssignmentHistory = [:] + persist() + } + + func clearContactAssignment(publicKey: String) { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + + let removedCurrent = ledger.contactAssignments.removeValue(forKey: normalizedKey) != nil + let removedHistory = ledger.contactAssignmentHistory.removeValue(forKey: normalizedKey) != nil + guard removedCurrent || removedHistory else { return } + + persist() + } + + func clearContactAssignments(excludingPublicKeys publicKeys: [String]) { + let normalizedKeys = Set(publicKeys.compactMap(PubkyPublicKeyFormat.normalized)) + let previousCount = ledger.contactAssignments.count + let previousHistoryCount = ledger.contactAssignmentHistory.count + ledger.contactAssignments = ledger.contactAssignments.filter { normalizedKeys.contains($0.key) } + ledger.contactAssignmentHistory = ledger.contactAssignmentHistory.filter { normalizedKeys.contains($0.key) } + guard ledger.contactAssignments.count != previousCount || ledger.contactAssignmentHistory.count != previousHistoryCount else { return } + + persist() + } + + private func clearCurrentContactAssignment(publicKey: String) { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + ledger.contactAssignments.removeValue(forKey: normalizedKey) != nil + else { return } + + persist() + } + + // MARK: - Private Address Allocation + + private func allocateAddress(for publicKey: String) async throws -> String { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.privateUnavailable + } + + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + let addressTypeKey = addressType.stringValue + + try await prepareReusableReceive(addressType: addressType) + + let addressInfo = try await LightningService.shared.newAddressInfoForType(addressType) + guard !isUnavailableForReusableReceive(receiveIndex: addressInfo.index, addressType: addressType) else { + throw AppError( + message: "Unable to reserve private Paykit address", + debugMessage: "LDK returned unavailable receive index \(addressInfo.index) after reservation reconciliation" + ) + } + + var reserved = ledger.reservedReceiveIndexesByAddressType[addressTypeKey] ?? [] + reserved.insert(addressInfo.index) + ledger.reservedReceiveIndexesByAddressType[addressTypeKey] = reserved + + let assignment = StoredAssignment( + addressType: addressTypeKey, + receiveIndex: addressInfo.index + ) + ledger.contactAssignments[normalizedKey] = assignment + rememberContactAssignmentForAttribution(publicKey: normalizedKey, assignment: assignment) + persist() + markWalletBackupDataChanged() + await ensureReusableOnchainAddress(afterReserving: addressInfo.address, addressType: addressType) + + return addressInfo.address + } + + private func rememberContactAssignmentForAttribution(publicKey: String, assignment: StoredAssignment) { + var history = ledger.contactAssignmentHistory[publicKey] ?? [] + guard !history.contains(assignment) else { return } + + history.append(assignment) + ledger.contactAssignmentHistory[publicKey] = history + } + + // MARK: - LDK Address Index APIs + + private func reconcileAddressTypeWithLdk(_ addressType: LDKNode.AddressType, highestReserved: UInt32) async throws { + try await LightningService.shared.revealReceiveAddresses(to: highestReserved, forType: addressType) + } + + private func address(for addressType: LDKNode.AddressType, receiveIndex: UInt32) async throws -> String { + let addressInfo = try await LightningService.shared.addressInfoForType(addressType, atIndex: receiveIndex) + return addressInfo.address + } + + private static func assignmentKey(_ assignment: StoredAssignment) -> String { + "\(assignment.addressType):\(assignment.receiveIndex)" + } + + // MARK: - Cached Receive Address + + private var reusableOnchainAddress: String { + UserDefaults.standard.string(forKey: "onchainAddress") ?? "" + } + + private func ensureReusableOnchainAddress(afterReserving reservedAddress: String, addressType: LDKNode.AddressType) async { + let currentAddress = reusableOnchainAddress + let currentIsUnavailable = await isUnavailableForReusableReceive(address: currentAddress) + if !currentAddress.isEmpty, currentAddress != reservedAddress, !currentIsUnavailable { + return + } + + do { + let replacement = try await nextNonReservedReceiveAddress(addressType: addressType) + UserDefaults.standard.set(replacement, forKey: "onchainAddress") + } catch { + UserDefaults.standard.set("", forKey: "onchainAddress") + Logger.warn("Failed to refresh reusable receive address after private Paykit reservation: \(error)", context: "PrivatePaykit") + } + } + + private func clearReusableOnchainAddressIfReserved() async { + let reusableAddress = reusableOnchainAddress + guard !reusableAddress.isEmpty else { return } + + if await isUnavailableForReusableReceive(address: reusableAddress) { + UserDefaults.standard.set("", forKey: "onchainAddress") + } + } + + // MARK: - Address Type Monitoring + + private func isAddressTypeMonitored(_ addressType: LDKNode.AddressType) -> Bool { + let state = LightningService.addressTypeStateFromUserDefaults(defaults) + return addressType == state.selectedType || state.monitoredTypes.contains(addressType) + } + + // MARK: - Persistence + + private func persist() { + Self.persist(ledger: ledger, defaults: defaults) + } + + private static func persist(ledger: Ledger, defaults: UserDefaults) { + do { + let encoded = try JSONEncoder().encode(ledger) + defaults.set(encoded, forKey: Self.defaultsKey) + } catch { + Logger.error("Failed to persist private Paykit reservation ledger: \(error)", context: "PrivatePaykit") + } + } + + private func markWalletBackupDataChanged() { + Self.walletBackupDataChangedSubject.send() + } + + // MARK: - Models + + private struct Ledger: Codable { + static let empty = Ledger( + version: PrivatePaykitAddressReservationStore.schemaVersion, + reservedReceiveIndexesByAddressType: [:], + contactAssignments: [:], + contactAssignmentHistory: [:], + restoredReservedReceiveIndexCeilingsByAddressType: [:] + ) + + var version: Int + var reservedReceiveIndexesByAddressType: [String: Set] + var contactAssignments: [String: StoredAssignment] + var contactAssignmentHistory: [String: [StoredAssignment]] + var restoredReservedReceiveIndexCeilingsByAddressType: [String: UInt32] + } + + private struct StoredAssignment: Codable, Equatable { + var addressType: String + var receiveIndex: UInt32 + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift new file mode 100644 index 00000000..2da1b7e5 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Backup.swift @@ -0,0 +1,101 @@ +import Foundation + +// MARK: - Backup + +extension PrivatePaykitService { + func backupSnapshot() -> [String: PrivatePaykitContactLinkBackupV1]? { + let contacts: [String: PrivatePaykitContactLinkBackupV1] = Dictionary( + uniqueKeysWithValues: state.contacts.compactMap { publicKey, contactState in + guard contactState.hasBackupState else { + return nil + } + + return ( + publicKey, + PrivatePaykitContactLinkBackupV1( + publicKey: publicKey, + linkSnapshotHex: contactState.linkSnapshotHex, + handshakeSnapshotHex: contactState.handshakeSnapshotHex, + remoteEndpoints: contactState.remoteEndpointMap, + linkCompletedAt: contactState.linkCompletedAt, + handshakeUpdatedAt: contactState.handshakeUpdatedAt, + recoveryStartedAt: contactState.recoveryStartedAt, + mainRecoveryAttemptId: contactState.mainRecoveryAttemptId, + responderRecoveryAttemptId: contactState.responderRecoveryAttemptId + ) + ) + } + ) + + guard !contacts.isEmpty else { return nil } + + return contacts + } + + func restoreBackup(_ backup: [String: PrivatePaykitContactLinkBackupV1]?) async { + resetInFlightWork() + await closeActivePaykitHandles() + activeHandlesByContact.removeAll() + knownSavedContactKeys.removeAll() + + guard let backup else { + state = PrivatePaykitState(contacts: [:]) + persistState() + return + } + + var restoredContacts: [String: ContactState] = [:] + for (publicKey, contactBackup) in backup { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { continue } + let linkSnapshotHex = await validatedSnapshot( + contactBackup.linkSnapshotHex, + publicKey: normalizedKey, + recipient: PubkyService.encryptedLinkSnapshotRecipient + ) + let handshakeSnapshotHex = await validatedSnapshot( + contactBackup.handshakeSnapshotHex, + publicKey: normalizedKey, + recipient: PubkyService.encryptedLinkHandshakeSnapshotRecipient + ) + + var contactState = ContactState() + contactState.linkSnapshotHex = linkSnapshotHex + contactState.handshakeSnapshotHex = handshakeSnapshotHex + contactState.remoteEndpoints = Self.storedPaymentEntries(from: contactBackup.remoteEndpoints) + contactState.linkCompletedAt = contactBackup.linkCompletedAt + contactState.handshakeUpdatedAt = contactBackup.handshakeUpdatedAt + contactState.recoveryStartedAt = contactBackup.recoveryStartedAt + contactState.mainRecoveryAttemptId = contactBackup.mainRecoveryAttemptId + contactState.responderRecoveryAttemptId = contactBackup.responderRecoveryAttemptId + restoredContacts[normalizedKey] = contactState + } + + state = PrivatePaykitState(contacts: restoredContacts) + persistState() + } + + func validatedSnapshot( + _ snapshotHex: String?, + publicKey: String, + recipient: (String) async throws -> String + ) async -> String? { + guard let snapshotHex else { return nil } + + do { + try await validateSnapshot(snapshotHex, publicKey: publicKey, recipient: recipient) + return snapshotHex + } catch { + Logger.warn( + "Dropping private Paykit snapshot with mismatched recipient for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + return nil + } + } + + static func storedPaymentEntries(from endpoints: [String: String]) -> [StoredPaymentEntry] { + endpoints + .sorted { $0.key < $1.key } + .map { StoredPaymentEntry(methodId: $0.key, endpointData: $0.value) } + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift new file mode 100644 index 00000000..77b94cdb --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -0,0 +1,393 @@ +import Foundation + +// MARK: - Saved Contacts + +extension PrivatePaykitService { + func prepareSavedContacts(_ publicKeys: [String], wallet: WalletViewModel) async { + let publicKeys = rememberSavedContacts(publicKeys, replacing: true) + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + await PrivatePaykitAddressReservationStore.shared.reconcileReservedIndexesWithLdk() + await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 3, reason: "prepare") + } + + func refreshSavedContactEndpoints(for publicKeys: [String], wallet: WalletViewModel) async { + let publicKeys = rememberSavedContacts(publicKeys, replacing: true) + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + await publishLocalEndpoints(for: publicKeys, wallet: wallet, maxAdvanceSteps: 1, reason: "refresh") + } + + func refreshKnownSavedContactEndpoints(wallet: WalletViewModel, reason: String, forceRefreshLightning: Bool = false) async { + guard !knownSavedContactKeys.isEmpty else { return } + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + await publishLocalEndpoints( + for: Array(knownSavedContactKeys), + wallet: wallet, + maxAdvanceSteps: 1, + reason: reason, + forceRefreshLightning: forceRefreshLightning + ) + } + + func removePublishedEndpoints() async throws { + invalidateLinkEstablishmentWork() + let publicKeys = Array(state.contacts.keys) + var firstError: Error? + + for publicKey in publicKeys { + let generation = stateGeneration + do { + try await removePublishedEndpoints(for: publicKey, generation: generation) + } catch { + await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) + if firstError == nil { + firstError = error + } + Logger.warn( + "Failed to remove private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + + if let firstError { + throw firstError + } + } + + func removeSavedContact(publicKey: String) async { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return } + invalidateLinkEstablishment(for: normalizedKey) + knownSavedContactKeys.remove(normalizedKey) + let generation = stateGeneration + + do { + try await removePublishedEndpoints(for: normalizedKey, generation: generation) + } catch { + await recordLinkFailure(publicKey: normalizedKey, error: error, generation: generation) + Self.setContactSharingCleanupPending(true) + Logger.warn( + "Failed to tombstone private Paykit endpoints for removed contact \(PubkyPublicKeyFormat.redacted(normalizedKey)): \(error)", + context: "PrivatePaykit" + ) + return + } + + await clearContactState(publicKey: normalizedKey) + await PrivatePaykitAddressReservationStore.shared.clearContactAssignment(publicKey: normalizedKey) + } + + func removeSavedContacts(publicKeys: [String]) async { + for publicKey in normalizedSavedContactKeys(publicKeys) { + await removeSavedContact(publicKey: publicKey) + } + } + + func pruneUnsavedContactState(savedPublicKeys publicKeys: [String]) async { + let savedKeys = Set(rememberSavedContacts(publicKeys, replacing: true)) + let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) } + + for publicKey in staleKeys { + await removeSavedContact(publicKey: publicKey) + } + + await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys)) + } + + func retryPendingEndpointRemoval(wallet: WalletViewModel, savedPublicKeys: [String]) async { + guard UserDefaults.standard.bool(forKey: Self.cleanupPendingKey) else { return } + + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + try await removePublishedEndpoints() + await clearUnsavedContactState(savedPublicKeys: savedPublicKeys) + Self.setContactSharingCleanupPending(false) + } catch { + Logger.warn("Failed to retry pending Paykit contact endpoint removal: \(error)", context: "PrivatePaykit") + } + } + + func clearUnsavedContactState(savedPublicKeys publicKeys: [String]) async { + let savedKeys = Set(normalizedSavedContactKeys(publicKeys)) + let staleKeys = state.contacts.keys.filter { !savedKeys.contains($0) } + + for publicKey in staleKeys { + await clearContactState(publicKey: publicKey) + } + + await PrivatePaykitAddressReservationStore.shared.clearContactAssignments(excludingPublicKeys: Array(savedKeys)) + } + + func publishLocalEndpoints( + for publicKeys: [String], + wallet: WalletViewModel, + maxAdvanceSteps: Int, + reason: String, + scheduleRetries: Bool = true, + forceLocalPublishWhenRemoteEmpty: Bool = false, + forceRefreshLightning: Bool = false + ) async { + let generation = stateGeneration + for publicKey in publicKeys { + do { + guard let normalizedKey = knownSavedContact(publicKey) else { + continue + } + + guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) else { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + continue + } + + if state.contacts[normalizedKey]?.lastLocalPayloadHash == nil { + if await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: 0), + !shouldDeferInitialLocalPublish(publicKey: normalizedKey, fetchedRemoteCount: 0) + { + try await publishLocalEndpoints( + to: normalizedKey, + linkId: linkId, + wallet: wallet, + generation: generation, + forceRefreshLightning: forceRefreshLightning + ) + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + continue + } + + let fetchedCount: Int + do { + fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) + } catch { + if shouldCountAsStaleLinkFailure(error) { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + continue + } + throw error + } + + guard await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + continue + } + + try await publishLocalEndpoints( + to: normalizedKey, + linkId: linkId, + wallet: wallet, + generation: generation, + forceRefreshLightning: forceRefreshLightning + ) + if fetchedCount == 0, state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + } else { + cancelPendingPublicationRetry(for: normalizedKey) + } + continue + } + + let fetchedCount: Int + do { + fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) + } catch { + if shouldCountAsStaleLinkFailure(error) { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + continue + } + throw error + } + + guard await shouldPublishLocalEndpoints(publicKey: normalizedKey, fetchedRemoteCount: fetchedCount) else { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + continue + } + + // Recovery retries may need to send the same map again if restored Noise counters diverged. + let shouldForcePublish = forceLocalPublishWhenRemoteEmpty && + fetchedCount == 0 && + state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false + try await publishLocalEndpoints( + to: normalizedKey, + linkId: linkId, + wallet: wallet, + generation: generation, + force: shouldForcePublish, + forceRefreshLightning: forceRefreshLightning + ) + if fetchedCount == 0, state.contacts[normalizedKey]?.remoteEndpoints.isEmpty != false { + if scheduleRetries { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + } else { + cancelPendingPublicationRetry(for: normalizedKey) + } + } catch { + Logger.warn( + "Failed to \(reason) private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + } + + func schedulePendingPublicationRetry( + for publicKey: String, + wallet: WalletViewModel, + remainingAttempts: Int = PrivatePaykitService.pendingPublicationRetryAttempts + ) { + guard remainingAttempts > 0, isKnownSavedContact(publicKey), pendingPublicationRetryTasks[publicKey] == nil else { + return + } + + let task = Task { [weak self, weak wallet] in + try? await Task.sleep(nanoseconds: Self.pendingPublicationRetryDelay) + guard !Task.isCancelled, let self, let wallet else { return } + await runPendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts) + } + pendingPublicationRetryTasks[publicKey] = task + } + + func runPendingPublicationRetry(for publicKey: String, wallet: WalletViewModel, remainingAttempts: Int) async { + guard pendingPublicationRetryTasks[publicKey] != nil else { return } + pendingPublicationRetryTasks[publicKey] = nil + guard isKnownSavedContact(publicKey), await canPublishPrivateEndpoints(wallet: wallet) else { return } + + await publishLocalEndpoints( + for: [publicKey], + wallet: wallet, + maxAdvanceSteps: 3, + reason: "retry", + scheduleRetries: false, + forceLocalPublishWhenRemoteEmpty: true + ) + + let contactState = state.contacts[publicKey] + let needsAnotherRetry = contactState?.linkCompletedAt == nil || + contactState?.lastLocalPayloadHash == nil || + contactState?.remoteEndpoints.isEmpty != false + if needsAnotherRetry { + schedulePendingPublicationRetry(for: publicKey, wallet: wallet, remainingAttempts: remainingAttempts - 1) + } + } + + func cancelPendingPublicationRetry(for publicKey: String) { + pendingPublicationRetryTasks.removeValue(forKey: publicKey)?.cancel() + } + + func normalizedSavedContactKeys(_ publicKeys: [String]) -> [String] { + var seen = Set() + return publicKeys.compactMap { publicKey in + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + seen.insert(normalizedKey).inserted + else { return nil } + + return normalizedKey + } + } + + func rememberSavedContacts(_ publicKeys: [String], replacing: Bool) -> [String] { + let normalizedKeys = normalizedSavedContactKeys(publicKeys) + if replacing { + knownSavedContactKeys = Set(normalizedKeys) + } else { + knownSavedContactKeys.formUnion(normalizedKeys) + } + return normalizedKeys + } + + func knownSavedContact(_ publicKey: String) -> String? { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + isKnownSavedContact(normalizedKey) + else { return nil } + + return normalizedKey + } + + func isKnownSavedContact(_ publicKey: String) -> Bool { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { return false } + return knownSavedContactKeys.contains(normalizedKey) + } + + func removePublishedEndpoints(for publicKey: String, generation: UInt64) async throws { + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let previousTask = publicationTasks[normalizedKey]?.task + let taskId = UUID() + let task = Task { [weak self] in + if let previousTask { + try? await previousTask.value + } + guard let self else { throw PrivatePaykitError.privateUnavailable } + try Task.checkCancellation() + try await removePublishedEndpointsUnlocked(for: normalizedKey, generation: generation) + } + publicationTasks[normalizedKey] = PublicationTask(id: taskId, task: task) + + do { + try await task.value + if publicationTasks[normalizedKey]?.id == taskId { + publicationTasks[normalizedKey] = nil + } + } catch { + if publicationTasks[normalizedKey]?.id == taskId { + publicationTasks[normalizedKey] = nil + } + throw error + } + } + + func removePublishedEndpointsUnlocked(for publicKey: String, generation: UInt64) async throws { + guard let linkId = try await existingOrRecoveredLinkIdForRemoval(for: publicKey, generation: generation) else { + if shouldRequirePrivateEndpointRemoval(publicKey: publicKey) { + throw PrivatePaykitError.privateUnavailable + } + return + } + + try ensureCurrentGeneration(generation) + let removalEntries = privateEndpointRemovalEntries() + try validateNoisePayload(entries: removalEntries) + try await PubkyService.setPrivatePayments(linkId: linkId, entries: removalEntries) + try ensureCurrentGeneration(generation) + state.contacts[publicKey]?.lastLocalPayloadHash = nil + state.contacts[publicKey]?.localInvoice = nil + try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) + let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized) + if let ownPublicKey { + await clearRecoveryMarker(from: ownPublicKey, to: publicKey) + } + } + + func existingOrRecoveredLinkIdForRemoval(for publicKey: String, generation: UInt64) async throws -> String? { + if let linkId = try await existingLinkId(for: publicKey, generation: generation) { + return linkId + } + + guard shouldRequirePrivateEndpointRemoval(publicKey: publicKey) else { + return nil + } + + return try await establishedLinkId(for: publicKey, maxAdvanceSteps: 5, generation: generation) + } + + func shouldRequirePrivateEndpointRemoval(publicKey: String) -> Bool { + guard let contactState = state.contacts[publicKey] else { return false } + + return contactState.linkSnapshotHex != nil || + contactState.lastLocalPayloadHash != nil || + contactState.localInvoice != nil || + contactState.linkCompletedAt != nil || + contactState.recoveryStartedAt != nil + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift new file mode 100644 index 00000000..dbfff5f2 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -0,0 +1,346 @@ +import CryptoKit +import Foundation +import Paykit + +// MARK: - Endpoint Publishing + +extension PrivatePaykitService { + static func isNoisePayloadWithinLimit(_ paymentMap: [String: String]) -> Bool { + guard let data = try? JSONSerialization.data(withJSONObject: paymentMap) else { + return false + } + return data.count <= maxNoisePayloadBytes + } + + func handleReceivedPayment(paymentHash: String, wallet: WalletViewModel) async { + let matchingContacts = state.contacts.compactMap { publicKey, contactState -> String? in + guard isKnownSavedContact(publicKey) else { return nil } + return contactState.localInvoice?.paymentHash == paymentHash ? publicKey : nil + } + + guard !matchingContacts.isEmpty else { return } + + for publicKey in matchingContacts { + rememberReceivedInvoicePaymentHash(paymentHash, publicKey: publicKey) + } + + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + + for publicKey in matchingContacts { + let generation = stateGeneration + do { + guard let linkId = try await establishedLinkId(for: publicKey, maxAdvanceSteps: 1, generation: generation) else { + continue + } + try await publishLocalEndpoints(to: publicKey, linkId: linkId, wallet: wallet, generation: generation) + } catch { + Logger.warn( + "Failed to rotate private Paykit invoice for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + } + + func reconcileReceivedPayments(wallet: WalletViewModel) async { + for paymentHash in await settledPrivateInvoicePaymentHashes() { + await handleReceivedPayment(paymentHash: paymentHash, wallet: wallet) + } + } + + func handleOnchainActivity(wallet: WalletViewModel) async { + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + let publicKeys = await PrivatePaykitAddressReservationStore.shared.contactsWithUsedReservedAddresses() + .filter(isKnownSavedContact) + guard !publicKeys.isEmpty else { return } + + await rotateOnchainEndpoints(for: publicKeys, wallet: wallet, reason: "on-chain rotation", forceRotate: false) + } + + func handleOnchainActivity(receivedAddresses: [String], wallet: WalletViewModel) async { + let receivedAddresses = receivedAddresses.filter { !$0.isEmpty } + guard !receivedAddresses.isEmpty else { + await handleOnchainActivity(wallet: wallet) + return + } + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + + var publicKeys = Set() + for address in receivedAddresses { + if let publicKey = await PrivatePaykitAddressReservationStore.shared.currentContactPublicKey(forReservedAddress: address), + isKnownSavedContact(publicKey) + { + publicKeys.insert(publicKey) + } + } + guard !publicKeys.isEmpty else { return } + + await rotateOnchainEndpoints(for: Array(publicKeys), wallet: wallet, reason: "on-chain transaction output rotation", forceRotate: true) + } + + private func rotateOnchainEndpoints(for publicKeys: [String], wallet: WalletViewModel, reason: String, forceRotate: Bool) async { + var rotatedPublicKeys: [String] = [] + for publicKey in publicKeys { + do { + if forceRotate { + _ = try await PrivatePaykitAddressReservationStore.shared.rotateAddress(for: publicKey) + } else { + _ = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) + } + rotatedPublicKeys.append(publicKey) + } catch { + Logger.warn( + "Failed to rotate used private Paykit address for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + + guard !rotatedPublicKeys.isEmpty else { return } + await publishLocalEndpoints(for: rotatedPublicKeys, wallet: wallet, maxAdvanceSteps: 1, reason: reason) + } + + func publishLocalEndpointsBestEffort(to publicKey: String, linkId: String, wallet: WalletViewModel, + generation: UInt64, context: String, fetchedRemoteCount: Int) async throws + { + guard await canPublishPrivateEndpoints(wallet: wallet) else { return } + guard await shouldPublishLocalEndpoints(publicKey: publicKey, fetchedRemoteCount: fetchedRemoteCount) else { return } + guard !shouldDeferInitialLocalPublish(publicKey: publicKey, fetchedRemoteCount: fetchedRemoteCount) else { return } + + do { + try await publishLocalEndpoints(to: publicKey, linkId: linkId, wallet: wallet, generation: generation) + } catch { + try Task.checkCancellation() + Logger.warn( + "Failed to publish local private Paykit endpoints during \(context) for \(PubkyPublicKeyFormat.redacted(publicKey)); continuing with remote fetch: \(error)", + context: "PrivatePaykit" + ) + } + } + + func shouldPublishLocalEndpoints(publicKey: String, fetchedRemoteCount: Int) async -> Bool { + let contactState = state.contacts[publicKey] + if contactState?.lastLocalPayloadHash != nil { + return true + } + + if fetchedRemoteCount > 0 || contactState?.remoteEndpoints.isEmpty == false { + return true + } + + guard let ownPublicKey = await PubkyService.currentPublicKey() else { + return false + } + + return Self.shouldInitiate(ownPublicKey: ownPublicKey, remotePublicKey: publicKey) + } + + func shouldDeferInitialLocalPublish(publicKey: String, fetchedRemoteCount: Int) -> Bool { + guard fetchedRemoteCount == 0, + let contactState = state.contacts[publicKey], + contactState.lastLocalPayloadHash == nil, + contactState.remoteEndpoints.isEmpty, + let linkCompletedAt = contactState.linkCompletedAt + else { + return false + } + + let now = UInt64(Date().timeIntervalSince1970) + return now <= linkCompletedAt + Self.freshLinkInitialPublishDelaySeconds + } + + func publishLocalEndpoints( + to publicKey: String, + linkId: String, + wallet: WalletViewModel, + generation: UInt64, + force: Bool = false, + forceRefreshLightning: Bool = false + ) async throws { + let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let previousTask = publicationTasks[normalizedKey]?.task + let taskId = UUID() + let task = Task { [weak self] in + if let previousTask { + try? await previousTask.value + } + guard let self else { throw PrivatePaykitError.privateUnavailable } + try Task.checkCancellation() + try await publishLocalEndpointsUnlocked( + to: normalizedKey, + linkId: linkId, + wallet: wallet, + generation: generation, + force: force, + forceRefreshLightning: forceRefreshLightning + ) + } + publicationTasks[normalizedKey] = PublicationTask(id: taskId, task: task) + + do { + try await task.value + if publicationTasks[normalizedKey]?.id == taskId { + publicationTasks[normalizedKey] = nil + } + } catch { + if publicationTasks[normalizedKey]?.id == taskId { + publicationTasks[normalizedKey] = nil + } + throw error + } + } + + func publishLocalEndpointsUnlocked( + to publicKey: String, + linkId: String, + wallet: WalletViewModel, + generation: UInt64, + force: Bool, + forceRefreshLightning: Bool + ) async throws { + guard await canPublishPrivateEndpoints(wallet: wallet), + isKnownSavedContact(publicKey) + else { return } + try ensureCurrentGeneration(generation) + let endpoints = try await buildLocalEndpoints( + for: publicKey, + wallet: wallet, + generation: generation, + forceRefreshLightning: forceRefreshLightning + ) + try ensureCurrentGeneration(generation) + guard !endpoints.isEmpty else { return } + + let entries = try entriesWithinNoiseLimit(from: endpoints, publicKey: publicKey) + let payloadHash = localPayloadHash(entries: entries) + guard force || state.contacts[publicKey]?.lastLocalPayloadHash != payloadHash else { + return + } + + try ensureCurrentGeneration(generation) + guard await canPublishPrivateEndpoints(wallet: wallet), + isKnownSavedContact(publicKey) + else { return } + + do { + try await PubkyService.setPrivatePayments(linkId: linkId, entries: entries) + try ensureCurrentGeneration(generation) + } catch { + await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) + throw error + } + + try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) + state.contacts[publicKey, default: ContactState()].lastLocalPayloadHash = payloadHash + persistState() + } + + func buildLocalEndpoints(for publicKey: String, wallet: WalletViewModel, + generation: UInt64, forceRefreshLightning: Bool = false) async throws -> [PublicPaykitService.Endpoint] + { + var endpoints: [PublicPaykitService.Endpoint] = [] + let reservedAddress = try await PrivatePaykitAddressReservationStore.shared.currentOrRotatedAddress(for: publicKey) + try ensureCurrentGeneration(generation) + let onchainPayload = try PublicPaykitService.serializePayload(value: reservedAddress) + endpoints.append( + PublicPaykitService.Endpoint( + methodId: PublicPaykitService.onchainMethodId(for: reservedAddress), + value: reservedAddress, + min: nil, + max: nil, + rawPayload: onchainPayload + ) + ) + + if await walletHasUsableChannels(wallet) { + do { + let invoice = try await currentOrRotatedInvoice( + for: publicKey, + wallet: wallet, + generation: generation, + forceRefresh: forceRefreshLightning + ) + try ensureCurrentGeneration(generation) + let invoicePayload = try PublicPaykitService.serializePayload(value: invoice.bolt11) + endpoints.append( + PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: invoice.bolt11, + min: nil, + max: nil, + rawPayload: invoicePayload + ) + ) + } catch { + try ensureCurrentGeneration(generation) + state.contacts[publicKey]?.localInvoice = nil + persistState() + Logger.warn( + "Failed to prepare private Paykit Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only: \(error)", + context: "PrivatePaykit" + ) + } + } else { + try ensureCurrentGeneration(generation) + state.contacts[publicKey]?.localInvoice = nil + persistState() + } + + return endpoints + } + + func validateNoisePayload(entries: [FfiPaymentEntry]) throws { + let map = Dictionary(uniqueKeysWithValues: entries.map { ($0.methodId, $0.endpointData) }) + guard Self.isNoisePayloadWithinLimit(map) else { + throw PrivatePaykitError.payloadTooLarge + } + } + + func entriesWithinNoiseLimit(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) throws -> [FfiPaymentEntry] { + let entries = endpoints.map { + FfiPaymentEntry(methodId: $0.methodId.rawValue, endpointData: $0.rawPayload) + } + + do { + try validateNoisePayload(entries: entries) + return entries + } catch let error as PrivatePaykitError { + guard case .payloadTooLarge = error else { + throw error + } + + let onchainOnlyEntries = entries.filter { $0.methodId != PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue } + guard onchainOnlyEntries.count < entries.count, !onchainOnlyEntries.isEmpty else { + throw error + } + + try validateNoisePayload(entries: onchainOnlyEntries) + state.contacts[publicKey]?.localInvoice = nil + persistState() + Logger.warn( + "Private Paykit endpoint map is too large with Lightning invoice for \(PubkyPublicKeyFormat.redacted(publicKey)); publishing on-chain only.", + context: "PrivatePaykit" + ) + return onchainOnlyEntries + } + } + + func privateEndpointRemovalEntries() -> [FfiPaymentEntry] { + PublicPaykitService.MethodId.publishableMethodIds.map { + FfiPaymentEntry(methodId: $0.rawValue, endpointData: Self.privateEndpointRemovalPayload) + } + } + + func localPayloadHash(entries: [FfiPaymentEntry]) -> String { + let payload = entries + .sorted { $0.methodId < $1.methodId } + .map { entry in + "\(entry.methodId.count):\(entry.methodId)\(entry.endpointData.count):\(entry.endpointData)" + } + .joined() + + return SHA256.hash(data: Data(payload.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Errors.swift b/Bitkit/Services/PrivatePaykitService+Errors.swift new file mode 100644 index 00000000..21788822 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Errors.swift @@ -0,0 +1,111 @@ +import Foundation +import LDKNode +import Paykit + +enum PrivatePaykitError: LocalizedError { + case privateUnavailable + case payloadTooLarge + case staleLinkState + + var errorDescription: String? { + switch self { + case .privateUnavailable: + "Private Paykit is not available." + case .payloadTooLarge: + "The private Paykit payload is too large." + case .staleLinkState: + "The private Paykit link state changed." + } + } +} + +// MARK: - Error Classification + +extension PrivatePaykitService { + static func isDuplicatePaymentError(_ error: Error) -> Bool { + if let nodeError = error as? NodeError { + if case .DuplicatePayment = nodeError { + return true + } + } + + let reason: String = if let appError = error as? AppError { + [appError.message, appError.debugMessage] + .compactMap { $0 } + .joined(separator: " ") + } else { + "\(error.localizedDescription) \(String(describing: error))" + } + + let lowercasedReason = reason.lowercased() + return lowercasedReason.contains("duplicate payment") || lowercasedReason.contains("duplicatepayment") + } + + func shouldCountAsStaleLinkFailure(_ error: Error) -> Bool { + if let paykitError = error as? PaykitFfiError { + switch paykitError { + case let .Transport(reason): + return isNoiseStateFailure(reason) || isEncryptedLinkStateFailure(reason) + case let .InvalidData(reason), let .NotFound(reason), let .Validation(reason): + return isEncryptedLinkStateFailure(reason) + case .Session: + return false + } + } + + let wrappedReason = staleLinkFailureReason(from: error) + return isNoiseStateFailure(wrappedReason) || isEncryptedLinkStateFailure(wrappedReason) + } + + func staleLinkFailureReason(from error: Error) -> String { + if let appError = error as? AppError { + return [appError.message, appError.debugMessage] + .compactMap { $0 } + .joined(separator: " ") + } + + return error.localizedDescription + } + + func isNoiseStateFailure(_ reason: String) -> Bool { + let lowercasedReason = reason.lowercased() + return [ + "decrypt", + "decryption", + "cipher", + "noise state", + "counter", + "invalid tag", + "bad mac", + ].contains { lowercasedReason.contains($0) } + } + + func isEncryptedLinkStateFailure(_ reason: String) -> Bool { + let lowercasedReason = reason.lowercased() + return [ + "unknown encrypted-link handle", + "unknown encrypted link handle", + "encrypted-link handle is closed", + "encrypted link handle is closed", + "failed to restore encrypted link", + "encrypted link restore requires transport-phase snapshot", + "remote_pubkey does not match snapshot recipient", + ].contains { lowercasedReason.contains($0) } + } + + func isEncryptedHandshakeStateFailure(_ error: Error) -> Bool { + let lowercasedReason = staleLinkFailureReason(from: error).lowercased() + return isNoiseStateFailure(lowercasedReason) || + isEncryptedLinkStateFailure(lowercasedReason) || + [ + "restoreplayerror", + "handshake restore failed", + ].contains { lowercasedReason.contains($0) } + } + + func isEncryptedHandshakePendingError(_ error: Error) -> Bool { + let lowercasedReason = staleLinkFailureReason(from: error).lowercased() + return lowercasedReason.contains("transition_transport failed") && + lowercasedReason.contains("ishandshake") + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Invoices.swift b/Bitkit/Services/PrivatePaykitService+Invoices.swift new file mode 100644 index 00000000..d493263b --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Invoices.swift @@ -0,0 +1,140 @@ +import BitkitCore +import Foundation +import LDKNode +import UIKit + +// MARK: - Invoice Rotation + +extension PrivatePaykitService { + func currentOrRotatedInvoice(for publicKey: String, wallet: WalletViewModel, generation: UInt64, + forceRefresh: Bool = false) async throws -> StoredInvoice + { + if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { + return invoice + } + + let bolt11 = try await createVariableInvoice(wallet) + try ensureCurrentGeneration(generation) + if !forceRefresh, let invoice = await reusablePrivateInvoice(for: publicKey) { + return invoice + } + + guard case let .lightning(decodedInvoice) = try await decode(invoice: bolt11) else { + throw PublicPaykitError.invalidPayload + } + + let invoice = StoredInvoice( + bolt11: bolt11, + paymentHash: decodedInvoice.paymentHash.hex, + expiresAt: Double(decodedInvoice.timestampSeconds + decodedInvoice.expirySeconds) + ) + state.contacts[publicKey, default: ContactState()].localInvoice = invoice + persistState() + return invoice + } + + func rememberReceivedInvoicePaymentHash(_ paymentHash: String, publicKey: String) { + guard !paymentHash.isEmpty else { return } + + var contactState = state.contacts[publicKey, default: ContactState()] + guard !contactState.receivedInvoicePaymentHashes.contains(paymentHash) else { return } + + contactState.receivedInvoicePaymentHashes.append(paymentHash) + if contactState.receivedInvoicePaymentHashes.count > Self.maxReceivedInvoicePaymentHashesPerContact { + contactState + .receivedInvoicePaymentHashes = Array(contactState.receivedInvoicePaymentHashes + .suffix(Self.maxReceivedInvoicePaymentHashesPerContact)) + } + state.contacts[publicKey] = contactState + persistState() + } + + func reusablePrivateInvoice(for publicKey: String) async -> StoredInvoice? { + guard let invoice = state.contacts[publicKey]?.localInvoice, + invoice.expiresAt > Date().timeIntervalSince1970 + Self.invoiceRefreshBufferSeconds, + await !isReceivedInvoiceSettled(paymentHash: invoice.paymentHash), + case let .lightning(decodedInvoice) = try? await decode(invoice: invoice.bolt11), + !decodedInvoice.isExpired, + decodedInvoice.amountSatoshis == 0 + else { + return nil + } + + return invoice + } + + func paymentHash(forBolt11 bolt11: String) async -> String? { + guard case let .lightning(decodedInvoice) = try? await decode(invoice: bolt11) else { + return nil + } + + return decodedInvoice.paymentHash.hex + } + + func hasAttemptedOutboundBolt11Payment(paymentHash: String) async -> Bool { + await attemptedOutboundBolt11PaymentHashes().contains(paymentHash) + } + + @MainActor + func walletHasUsableChannels(_ wallet: WalletViewModel) -> Bool { + wallet.hasUsableChannels + } + + @MainActor + func canPublishPrivateEndpoints(wallet: WalletViewModel) -> Bool { + UserDefaults.standard.bool(forKey: Self.publishingEnabledKey) && + UIApplication.shared.applicationState == .active && + wallet.walletExists == true && + wallet.nodeLifecycleState == .running + } + + @MainActor + func createVariableInvoice(_ wallet: WalletViewModel) async throws -> String { + try await wallet.createInvoice(amountSats: nil, note: "") + } + + func settledPrivateInvoicePaymentHashes() async -> [String] { + let settledHashes = await receivedSettledPaymentHashes() + return state.contacts.compactMap { _, contactState in + guard let paymentHash = contactState.localInvoice?.paymentHash, + settledHashes.contains(paymentHash) + else { return nil } + + return paymentHash + } + } + + func isReceivedInvoiceSettled(paymentHash: String) async -> Bool { + await receivedSettledPaymentHashes().contains(paymentHash) + } + + func receivedSettledPaymentHashes() async -> Set { + guard let payments = await LightningService.shared.listPayments() else { return [] } + + return Set( + payments.compactMap { payment in + guard payment.direction == .inbound, + payment.status == .succeeded, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + ) + } + + func attemptedOutboundBolt11PaymentHashes() async -> Set { + guard let payments = await LightningService.shared.listPayments() else { return [] } + + return Set( + payments.compactMap { payment in + guard payment.direction == .outbound, + payment.status != .failed, + case .bolt11 = payment.kind + else { return nil } + + return payment.id + } + ) + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Links.swift b/Bitkit/Services/PrivatePaykitService+Links.swift new file mode 100644 index 00000000..2457c2fe --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Links.swift @@ -0,0 +1,617 @@ +import CryptoKit +import Foundation +import Paykit + +// MARK: - Link Lifecycle + +extension PrivatePaykitService { + func establishedLinkId(for publicKey: String, maxAdvanceSteps: Int, generation: UInt64) async throws -> String? { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.privateUnavailable + } + + while true { + try Task.checkCancellation() + if let inFlight = linkEstablishmentTasks[normalizedKey] { + do { + let linkId = try await inFlight.task.value + if linkEstablishmentTasks[normalizedKey]?.id == inFlight.id { + linkEstablishmentTasks[normalizedKey] = nil + } + if linkId != nil || inFlight.maxAdvanceSteps >= maxAdvanceSteps { + return linkId + } + + continue + } catch { + if linkEstablishmentTasks[normalizedKey]?.id == inFlight.id { + linkEstablishmentTasks[normalizedKey] = nil + } + throw error + } + } + + let taskId = UUID() + let task = Task { [weak self] in + guard let self else { throw PrivatePaykitError.privateUnavailable } + try Task.checkCancellation() + return try await establishedLinkIdUnlocked(for: normalizedKey, maxAdvanceSteps: maxAdvanceSteps, generation: generation) + } + linkEstablishmentTasks[normalizedKey] = LinkEstablishmentTask(id: taskId, maxAdvanceSteps: maxAdvanceSteps, task: task) + + do { + let linkId = try await task.value + if linkEstablishmentTasks[normalizedKey]?.id == taskId { + linkEstablishmentTasks[normalizedKey] = nil + } + return linkId + } catch { + if linkEstablishmentTasks[normalizedKey]?.id == taskId { + linkEstablishmentTasks[normalizedKey] = nil + } + throw error + } + } + } + + func establishedLinkIdUnlocked(for normalizedKey: String, maxAdvanceSteps: Int, generation: UInt64) async throws -> String? { + try ensureCurrentGeneration(generation) + guard + let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), + !secretKeyHex.isEmpty, + let ownPublicKeyRaw = await PubkyService.currentPublicKey(), + let ownPublicKey = PubkyPublicKeyFormat.normalized(ownPublicKeyRaw) + else { + throw PrivatePaykitError.privateUnavailable + } + + if let linkId = activeHandlesByContact[normalizedKey]?.linkId { + if let remoteRecoveryMarker = await freshRecoveryMarker(from: normalizedKey, to: ownPublicKey, stages: [Self.recoveryMarkerStageInit]) { + if shouldReplaceUsableLink(with: remoteRecoveryMarker, publicKey: normalizedKey) { + guard await discardLinkForRecovery(publicKey: normalizedKey, linkId: linkId, startedAt: remoteRecoveryMarker.createdAt) else { + return nil + } + try ensureCurrentGeneration(generation) + } else { + try ensureCurrentGeneration(generation) + return linkId + } + } else { + try ensureCurrentGeneration(generation) + return linkId + } + } + + if let linkId = activeHandlesByContact[normalizedKey]?.linkId { + try ensureCurrentGeneration(generation) + return linkId + } + + if let snapshotHex = state.contacts[normalizedKey]?.linkSnapshotHex { + do { + try await validateSnapshot(snapshotHex, publicKey: normalizedKey, recipient: PubkyService.encryptedLinkSnapshotRecipient) + let linkId = try await PubkyService.restoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + try ensureCurrentGeneration(generation) + activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId: linkId, handshakeId: nil) + if let remoteRecoveryMarker = await freshRecoveryMarker( + from: normalizedKey, + to: ownPublicKey, + stages: [Self.recoveryMarkerStageInit] + ) { + if shouldReplaceUsableLink(with: remoteRecoveryMarker, publicKey: normalizedKey) { + guard await discardLinkForRecovery(publicKey: normalizedKey, linkId: linkId, startedAt: remoteRecoveryMarker.createdAt) else { + return nil + } + try ensureCurrentGeneration(generation) + } else { + try ensureCurrentGeneration(generation) + return linkId + } + } else { + try ensureCurrentGeneration(generation) + return linkId + } + } catch { + try ensureCurrentGeneration(generation) + Logger.warn("Failed to restore private Paykit link, restarting handshake: \(error)", context: "PrivatePaykit") + state.contacts[normalizedKey]?.linkSnapshotHex = nil + state.contacts[normalizedKey]?.handshakeSnapshotHex = nil + state.contacts[normalizedKey]?.lastLocalPayloadHash = nil + state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil + state.contacts[normalizedKey]?.responderRecoveryAttemptId = nil + persistState(markWalletBackup: true) + } + } + + let isRecovering = await shouldStartRecoveryHandshake(publicKey: normalizedKey) + let remoteRecoveryInitMarker = await freshRecoveryMarker(from: normalizedKey, to: ownPublicKey, stages: [Self.recoveryMarkerStageInit]) + .flatMap { isCompletedRecoveryMarker($0, publicKey: normalizedKey) ? nil : $0 } + let remoteRecoveryFinalForResponder: RecoveryMarker? = if let responderAttemptId = state.contacts[normalizedKey]?.responderRecoveryAttemptId { + await freshRecoveryMarker( + from: normalizedKey, + to: ownPublicKey, + stages: [Self.recoveryMarkerStageFinal], + attemptId: responderAttemptId + ) + } else { + nil + } + let remoteRecoveryMarker = remoteRecoveryInitMarker ?? remoteRecoveryFinalForResponder + + let initialMainRecoveryAttemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId + let localMainRecoveryMarker: RecoveryMarker? = if let mainRecoveryAttemptId = initialMainRecoveryAttemptId { + await freshRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stages: [Self.recoveryMarkerStageInit, Self.recoveryMarkerStageFinal], + attemptId: mainRecoveryAttemptId + ) + } else { + nil + } + + let shouldAcceptRemoteRecovery = if remoteRecoveryFinalForResponder != nil { + true + } else { + remoteRecoveryMarker.map { + shouldAcceptRemoteRecoveryMarker( + remoteMarker: $0, + localMarker: localMainRecoveryMarker, + ownPublicKey: ownPublicKey, + remotePublicKey: normalizedKey + ) + } ?? false + } + + if shouldAcceptRemoteRecovery, let remoteRecoveryMarker { + let isNewResponderAttempt = state.contacts[normalizedKey]?.responderRecoveryAttemptId != remoteRecoveryMarker.attemptId + if isNewResponderAttempt { + guard await purgePrivatePaymentOutbox(for: normalizedKey, reason: "recovery responder") else { + return nil + } + try ensureCurrentGeneration(generation) + + if let handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId { + try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) + } + + var handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] + handles.linkId = nil + handles.handshakeId = nil + activeHandlesByContact[normalizedKey] = handles + + state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = nil + state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil + state.contacts[normalizedKey]?.responderRecoveryAttemptId = remoteRecoveryMarker.attemptId + state.contacts[normalizedKey]?.recoveryStartedAt = remoteRecoveryMarker.createdAt + state.contacts[normalizedKey]?.lastLocalPayloadHash = nil + state.contacts[normalizedKey]?.remoteEndpoints = [] + persistState(markWalletBackup: true) + } + + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageResponse, + attemptId: remoteRecoveryMarker.attemptId, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + } + + let shouldInitiateRecovery = isRecovering && !shouldAcceptRemoteRecovery + if shouldInitiateRecovery, state.contacts[normalizedKey]?.mainRecoveryAttemptId == nil { + guard await purgePrivatePaymentOutbox(for: normalizedKey, reason: "recovery initiator") else { + return nil + } + try ensureCurrentGeneration(generation) + + if let handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId { + try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) + } + + var handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] + handles.linkId = nil + handles.handshakeId = nil + activeHandlesByContact[normalizedKey] = handles + + let attemptId = UUID().uuidString + let createdAt = UInt64(Date().timeIntervalSince1970) + state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = nil + state.contacts[normalizedKey]?.mainRecoveryAttemptId = attemptId + state.contacts[normalizedKey]?.responderRecoveryAttemptId = nil + state.contacts[normalizedKey]?.recoveryStartedAt = createdAt + state.contacts[normalizedKey]?.lastLocalPayloadHash = nil + state.contacts[normalizedKey]?.remoteEndpoints = [] + persistState(markWalletBackup: true) + + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageInit, + attemptId: attemptId, + createdAt: createdAt + ) + } + + if shouldInitiateRecovery, + initialMainRecoveryAttemptId != nil, + let mainRecoveryAttemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId, + localMainRecoveryMarker == nil + { + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageInit, + attemptId: mainRecoveryAttemptId, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + } + + if isRecovering, !shouldAcceptRemoteRecovery, + state.contacts[normalizedKey]?.responderRecoveryAttemptId != nil + { + state.contacts[normalizedKey]?.responderRecoveryAttemptId = nil + persistState(markWalletBackup: true) + } + + if shouldInitiateRecovery, + let attemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId, + state.contacts[normalizedKey]?.handshakeSnapshotHex != nil + { + let hasPeerProgress = await freshRecoveryMarker( + from: normalizedKey, + to: ownPublicKey, + stages: [Self.recoveryMarkerStageResponse, Self.recoveryMarkerStageFinal], + attemptId: attemptId + ) != nil + guard hasPeerProgress else { + return nil + } + } + + if shouldAcceptRemoteRecovery, + let attemptId = state.contacts[normalizedKey]?.responderRecoveryAttemptId, + state.contacts[normalizedKey]?.handshakeSnapshotHex != nil + { + let hasPeerFinal = await freshRecoveryMarker( + from: normalizedKey, + to: ownPublicKey, + stages: [Self.recoveryMarkerStageFinal], + attemptId: attemptId + ) != nil + guard hasPeerFinal else { + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageResponse, + attemptId: attemptId, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + return nil + } + } + + var handshakeId = activeHandlesByContact[normalizedKey]?.handshakeId + + if handshakeId == nil, let snapshotHex = state.contacts[normalizedKey]?.handshakeSnapshotHex { + do { + try await validateSnapshot(snapshotHex, publicKey: normalizedKey, recipient: PubkyService.encryptedLinkHandshakeSnapshotRecipient) + handshakeId = try await PubkyService.restoreEncryptedLinkHandshake(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + } catch { + try ensureCurrentGeneration(generation) + Logger.warn("Failed to restore private Paykit handshake, restarting: \(error)", context: "PrivatePaykit") + state.contacts[normalizedKey]?.handshakeSnapshotHex = nil + state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil + persistState(markWalletBackup: true) + } + } + + if handshakeId == nil { + let shouldInitiate = shouldInitiateRecovery || (!shouldAcceptRemoteRecovery && Self.shouldInitiate( + ownPublicKey: ownPublicKey, + remotePublicKey: normalizedKey + )) + if shouldInitiate { + handshakeId = try await PubkyService.initiateEncryptedLink(secretKeyHex: secretKeyHex, receiverPublicKey: normalizedKey) + try ensureCurrentGeneration(generation) + if isRecovering { + state.contacts[normalizedKey, default: ContactState()].recoveryStartedAt = UInt64(Date().timeIntervalSince1970) + persistState(markWalletBackup: true) + } + } else { + handshakeId = try await PubkyService.acceptEncryptedLink(secretKeyHex: secretKeyHex, senderPublicKey: normalizedKey) + } + } + + let isRecoveryHandshake = shouldInitiateRecovery || shouldAcceptRemoteRecovery + guard var handshakeId else { return nil } + try ensureCurrentGeneration(generation) + var handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] + handles.linkId = nil + handles.handshakeId = handshakeId + activeHandlesByContact[normalizedKey] = handles + + for _ in 0 ..< maxAdvanceSteps { + let progress: FfiHandshakeProgress + do { + progress = try await PubkyService.advanceHandshake(handshakeId: handshakeId) + } catch { + try ensureCurrentGeneration(generation) + if isEncryptedHandshakePendingError(error) { + let snapshotHex = try await PubkyService.serializeEncryptedLinkHandshake(handshakeId: handshakeId) + try ensureCurrentGeneration(generation) + state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = snapshotHex + state.contacts[normalizedKey]?.handshakeUpdatedAt = UInt64(Date().timeIntervalSince1970) + persistState(markWalletBackup: true) + return nil + } + if isEncryptedHandshakeStateFailure(error) { + activeHandlesByContact[normalizedKey]?.handshakeId = nil + state.contacts[normalizedKey]?.handshakeSnapshotHex = nil + state.contacts[normalizedKey]?.mainRecoveryAttemptId = nil + persistState(markWalletBackup: true) + } + throw error + } + try ensureCurrentGeneration(generation) + if progress.status == "complete" { + let linkId = progress.handleId + let attemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId ?? state.contacts[normalizedKey]?.responderRecoveryAttemptId + activeHandlesByContact[normalizedKey] = ContactPaykitHandles(linkId: linkId, handshakeId: nil) + state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = nil + state.contacts[normalizedKey]?.recoveryStartedAt = nil + try await persistLinkSnapshot(linkId: linkId, publicKey: normalizedKey, generation: generation, linkWasReplaced: true) + if isRecoveryHandshake, let attemptId { + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageFinal, + attemptId: attemptId, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + } + return linkId + } + + handshakeId = progress.handleId + handles = activeHandlesByContact[normalizedKey, default: ContactPaykitHandles()] + handles.linkId = nil + handles.handshakeId = handshakeId + activeHandlesByContact[normalizedKey] = handles + let snapshotHex = try await PubkyService.serializeEncryptedLinkHandshake(handshakeId: handshakeId) + try ensureCurrentGeneration(generation) + state.contacts[normalizedKey, default: ContactState()].handshakeSnapshotHex = snapshotHex + state.contacts[normalizedKey]?.handshakeUpdatedAt = UInt64(Date().timeIntervalSince1970) + persistState(markWalletBackup: true) + + if isRecoveryHandshake { + let createdAt = UInt64(Date().timeIntervalSince1970) + if shouldInitiateRecovery, let attemptId = state.contacts[normalizedKey]?.mainRecoveryAttemptId { + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageInit, + attemptId: attemptId, + createdAt: createdAt + ) + } else if shouldAcceptRemoteRecovery, let attemptId = state.contacts[normalizedKey]?.responderRecoveryAttemptId { + await publishRecoveryMarker( + from: ownPublicKey, + to: normalizedKey, + stage: Self.recoveryMarkerStageResponse, + attemptId: attemptId, + createdAt: createdAt + ) + } + return nil + } + } + + return nil + } + + func shouldStartRecoveryHandshake(publicKey: String) async -> Bool { + guard let contactState = state.contacts[publicKey], + contactState.linkSnapshotHex == nil + else { + return false + } + + if contactState.recoveryStartedAt != nil || contactState.mainRecoveryAttemptId != nil { + return true + } + + guard contactState.handshakeSnapshotHex == nil else { + return false + } + + if contactState.linkCompletedAt != nil || contactState.handshakeUpdatedAt != nil { + return true + } + + return await PrivatePaykitAddressReservationStore.shared.hasContactAssignment(for: publicKey) + } + + func discardLinkForRecovery(publicKey: String, linkId: String?, startedAt: UInt64) async -> Bool { + if let linkId { + try? await PubkyService.closeEncryptedLink(linkId: linkId) + } + + var handles = activeHandlesByContact[publicKey, default: ContactPaykitHandles()] + handles.linkId = nil + handles.handshakeId = nil + activeHandlesByContact[publicKey] = handles + state.contacts[publicKey]?.linkSnapshotHex = nil + state.contacts[publicKey]?.handshakeSnapshotHex = nil + state.contacts[publicKey]?.lastLocalPayloadHash = nil + state.contacts[publicKey]?.remoteEndpoints = [] + state.contacts[publicKey]?.recoveryStartedAt = startedAt + state.contacts[publicKey]?.mainRecoveryAttemptId = nil + state.contacts[publicKey]?.responderRecoveryAttemptId = nil + persistState(markWalletBackup: true) + return true + } + + func shouldAcceptRemoteRecoveryMarker(remoteMarker: RecoveryMarker, localMarker: RecoveryMarker?, + ownPublicKey: String, remotePublicKey: String) -> Bool + { + guard let localMarker else { return true } + + if remoteMarker.createdAt != localMarker.createdAt { + return remoteMarker.createdAt < localMarker.createdAt + } + + if remoteMarker.attemptId != localMarker.attemptId { + return remoteMarker.attemptId < localMarker.attemptId + } + + return remotePublicKey < ownPublicKey + } + + func isCompletedRecoveryMarker(_ marker: RecoveryMarker, publicKey: String) -> Bool { + state.contacts[publicKey]?.lastCompletedRecoveryAttemptId == marker.attemptId + } + + func shouldReplaceUsableLink(with marker: RecoveryMarker, publicKey: String) -> Bool { + guard !isCompletedRecoveryMarker(marker, publicKey: publicKey) else { + return false + } + + guard let linkCompletedAt = state.contacts[publicKey]?.linkCompletedAt else { + return true + } + + return marker.createdAt > linkCompletedAt + Self.completedLinkRecoveryMarkerGraceSeconds + } + + func validateSnapshot( + _ snapshotHex: String, + publicKey: String, + recipient: (String) async throws -> String + ) async throws { + let snapshotRecipient = try await recipient(snapshotHex) + guard PubkyPublicKeyFormat.normalized(snapshotRecipient) == PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.privateUnavailable + } + } + + static func recoveryMarkerPath(from writerPublicKey: String, to readerPublicKey: String) -> String? { + guard let writerPublicKey = PubkyPublicKeyFormat.normalized(writerPublicKey), + let readerPublicKey = PubkyPublicKeyFormat.normalized(readerPublicKey) + else { return nil } + + let material = "bitkit-private-paykit-recovery-v1|\(writerPublicKey)|\(readerPublicKey)" + let markerId = SHA256.hash(data: Data(material.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return "/pub/paykit/v0/private-recovery/\(markerId).json" + } + + func freshRecoveryMarker(from writerPublicKey: String, to readerPublicKey: String, stages: Set, + attemptId: String? = nil) async -> RecoveryMarker? + { + guard let markerUri = Self.recoveryMarkerUri(from: writerPublicKey, to: readerPublicKey), + let markerPath = Self.recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey), + let payload = try? await PubkyService.fetchFileString(uri: markerUri), + let data = payload.data(using: .utf8), + let marker = try? JSONDecoder().decode(RecoveryMarker.self, from: data), + marker.version == 1, + marker.path == markerPath, + stages.contains(marker.stage), + !marker.attemptId.isEmpty + else { + return nil + } + + let contactKey = [writerPublicKey, readerPublicKey] + .compactMap(PubkyPublicKeyFormat.normalized) + .first { state.contacts[$0] != nil } + let linkCompletedAt = contactKey.flatMap { state.contacts[$0]?.linkCompletedAt } ?? 0 + guard marker.createdAt > linkCompletedAt else { + return nil + } + + if let attemptId, marker.attemptId != attemptId { + return nil + } + + return marker + } + + func publishRecoveryMarker(from writerPublicKey: String, to readerPublicKey: String, stage: String, attemptId: String, createdAt: UInt64) async { + guard let markerPath = Self.recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey), + let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty, + !attemptId.isEmpty + else { return } + + let marker = RecoveryMarker(version: 1, path: markerPath, stage: stage, attemptId: attemptId, createdAt: createdAt) + do { + let data = try JSONEncoder().encode(marker) + try await PubkyService.sessionPut(sessionSecret: sessionSecret, path: markerPath, content: data) + } catch { + Logger.warn( + "Failed to publish private Paykit recovery marker for \(PubkyPublicKeyFormat.redacted(readerPublicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + + func clearRecoveryMarker(from writerPublicKey: String, to readerPublicKey: String) async { + guard let markerPath = Self.recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey), + let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty + else { return } + + try? await PubkyService.sessionDelete(sessionSecret: sessionSecret, path: markerPath) + } + + private static func recoveryMarkerUri(from writerPublicKey: String, to readerPublicKey: String) -> String? { + guard let writerPublicKey = PubkyPublicKeyFormat.normalized(writerPublicKey), + let path = recoveryMarkerPath(from: writerPublicKey, to: readerPublicKey) + else { return nil } + + return "pubky://\(writerPublicKey.dropFirst("pubky".count))\(path)" + } + + @discardableResult + func existingLinkId(for publicKey: String, generation: UInt64) async throws -> String? { + try ensureCurrentGeneration(generation) + if let linkId = activeHandlesByContact[publicKey]?.linkId { + return linkId + } + + guard let snapshotHex = state.contacts[publicKey]?.linkSnapshotHex, + let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), + !secretKeyHex.isEmpty + else { + return nil + } + + let linkId = try await PubkyService.restoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + try ensureCurrentGeneration(generation) + activeHandlesByContact[publicKey] = ContactPaykitHandles(linkId: linkId, handshakeId: nil) + return linkId + } + + func restoreLinkHandleForReadRetry(publicKey: String, generation: UInt64) async throws -> String? { + try ensureCurrentGeneration(generation) + guard let snapshotHex = state.contacts[publicKey]?.linkSnapshotHex, + let secretKeyHex = try Keychain.loadString(key: .pubkySecretKey), + !secretKeyHex.isEmpty + else { + return nil + } + + if let linkId = activeHandlesByContact[publicKey]?.linkId { + try? await PubkyService.closeEncryptedLink(linkId: linkId) + } + activeHandlesByContact[publicKey]?.linkId = nil + + try ensureCurrentGeneration(generation) + let restoredLinkId = try await PubkyService.restoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + try ensureCurrentGeneration(generation) + activeHandlesByContact[publicKey] = ContactPaykitHandles(linkId: restoredLinkId, handshakeId: nil) + return restoredLinkId + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Models.swift b/Bitkit/Services/PrivatePaykitService+Models.swift new file mode 100644 index 00000000..4cbafed0 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Models.swift @@ -0,0 +1,211 @@ +import Foundation +import Paykit + +// MARK: - State Models + +extension PrivatePaykitService { + struct ContactPaykitHandles { + var linkId: String? + var handshakeId: String? + } + + struct LinkEstablishmentTask { + var id: UUID + var maxAdvanceSteps: Int + var task: Task + } + + struct PublicationTask { + var id: UUID + var task: Task + } + + struct PrivateStoragePurgeResult { + var deletedCount: Int + var didHitLimit: Bool + var didFail: Bool + } + + struct PrivatePaykitState { + var contacts: [String: ContactState] + + init(contacts: [String: ContactState]) { + self.contacts = contacts + } + + init(secretState: PrivatePaykitSecretState, cacheState: PrivatePaykitCacheState) { + var contacts = cacheState.contacts.mapValues(ContactState.init(cacheState:)) + + for (publicKey, secretState) in secretState.contacts { + var contactState = contacts[publicKey, default: ContactState()] + contactState.linkSnapshotHex = secretState.linkSnapshotHex + contactState.handshakeSnapshotHex = secretState.handshakeSnapshotHex + contacts[publicKey] = contactState + } + + self.contacts = contacts + } + + var secretState: PrivatePaykitSecretState { + PrivatePaykitSecretState( + contacts: contacts.compactMapValues { contactState in + let secretState = ContactSecretState(contactState: contactState) + return secretState.hasSecretState ? secretState : nil + } + ) + } + + var cacheState: PrivatePaykitCacheState { + PrivatePaykitCacheState( + contacts: contacts.compactMapValues { contactState in + let cacheState = ContactCacheState(contactState: contactState) + return cacheState.hasCacheState ? cacheState : nil + } + ) + } + } + + struct PrivatePaykitSecretState: Codable { + var contacts: [String: ContactSecretState] + } + + struct PrivatePaykitCacheState: Codable { + var contacts: [String: ContactCacheState] + } + + struct ContactState: Codable { + var linkSnapshotHex: String? + var handshakeSnapshotHex: String? + var remoteEndpoints: [StoredPaymentEntry] = [] + var localInvoice: StoredInvoice? + var receivedInvoicePaymentHashes: [String] = [] + var lastLocalPayloadHash: String? + var linkCompletedAt: UInt64? + var handshakeUpdatedAt: UInt64? + var recoveryStartedAt: UInt64? + var mainRecoveryAttemptId: String? + var responderRecoveryAttemptId: String? + var lastCompletedRecoveryAttemptId: String? + var linkFailureCount: Int = 0 + + init() {} + + init(cacheState: ContactCacheState) { + remoteEndpoints = cacheState.remoteEndpoints + localInvoice = cacheState.localInvoice + receivedInvoicePaymentHashes = cacheState.receivedInvoicePaymentHashes + lastLocalPayloadHash = cacheState.lastLocalPayloadHash + linkCompletedAt = cacheState.linkCompletedAt + handshakeUpdatedAt = cacheState.handshakeUpdatedAt + recoveryStartedAt = cacheState.recoveryStartedAt + mainRecoveryAttemptId = cacheState.mainRecoveryAttemptId + responderRecoveryAttemptId = cacheState.responderRecoveryAttemptId + lastCompletedRecoveryAttemptId = cacheState.lastCompletedRecoveryAttemptId + linkFailureCount = cacheState.linkFailureCount + } + + var remoteEndpointMap: [String: String] { + remoteEndpoints.reduce(into: [:]) { map, entry in + map[entry.methodId] = entry.endpointData + } + } + + var hasBackupState: Bool { + linkSnapshotHex != nil || + handshakeSnapshotHex != nil || + !remoteEndpoints.isEmpty || + linkCompletedAt != nil || + handshakeUpdatedAt != nil || + recoveryStartedAt != nil || + mainRecoveryAttemptId != nil || + responderRecoveryAttemptId != nil || + lastCompletedRecoveryAttemptId != nil + } + } + + struct ContactSecretState: Codable { + var linkSnapshotHex: String? + var handshakeSnapshotHex: String? + + init(contactState: ContactState) { + linkSnapshotHex = contactState.linkSnapshotHex + handshakeSnapshotHex = contactState.handshakeSnapshotHex + } + + var hasSecretState: Bool { + linkSnapshotHex != nil || + handshakeSnapshotHex != nil + } + } + + struct ContactCacheState: Codable { + var remoteEndpoints: [StoredPaymentEntry] = [] + var localInvoice: StoredInvoice? + var receivedInvoicePaymentHashes: [String] = [] + var lastLocalPayloadHash: String? + var linkCompletedAt: UInt64? + var handshakeUpdatedAt: UInt64? + var recoveryStartedAt: UInt64? + var mainRecoveryAttemptId: String? + var responderRecoveryAttemptId: String? + var lastCompletedRecoveryAttemptId: String? + var linkFailureCount: Int = 0 + + init(contactState: ContactState) { + remoteEndpoints = contactState.remoteEndpoints + localInvoice = contactState.localInvoice + receivedInvoicePaymentHashes = contactState.receivedInvoicePaymentHashes + lastLocalPayloadHash = contactState.lastLocalPayloadHash + linkCompletedAt = contactState.linkCompletedAt + handshakeUpdatedAt = contactState.handshakeUpdatedAt + recoveryStartedAt = contactState.recoveryStartedAt + mainRecoveryAttemptId = contactState.mainRecoveryAttemptId + responderRecoveryAttemptId = contactState.responderRecoveryAttemptId + lastCompletedRecoveryAttemptId = contactState.lastCompletedRecoveryAttemptId + linkFailureCount = contactState.linkFailureCount + } + + var hasCacheState: Bool { + !remoteEndpoints.isEmpty || + localInvoice != nil || + !receivedInvoicePaymentHashes.isEmpty || + lastLocalPayloadHash != nil || + linkCompletedAt != nil || + handshakeUpdatedAt != nil || + recoveryStartedAt != nil || + mainRecoveryAttemptId != nil || + responderRecoveryAttemptId != nil || + lastCompletedRecoveryAttemptId != nil || + linkFailureCount != 0 + } + } + + struct StoredPaymentEntry: Codable { + var methodId: String + var endpointData: String + + init(entry: FfiPaymentEntry) { + methodId = entry.methodId + endpointData = entry.endpointData + } + + init(methodId: String, endpointData: String) { + self.methodId = methodId + self.endpointData = endpointData + } + } + + struct StoredInvoice: Codable { + var bolt11: String + var paymentHash: String + var expiresAt: Double + } + + struct RecoveryMarker: Codable { + var version: Int + var path: String + var stage: String + var attemptId: String + var createdAt: UInt64 + } +} diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift new file mode 100644 index 00000000..dd716963 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -0,0 +1,350 @@ +import Foundation + +// MARK: - Payment Resolution + +extension PrivatePaykitService { + func hasCachedPrivateEndpoint(publicKey: String) async -> Bool { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + let contactState = state.contacts[normalizedKey] + else { return false } + + let endpoints = contactState.remoteEndpoints.compactMap { + PublicPaykitService.parseEndpoint(methodId: $0.methodId, endpointData: $0.endpointData) + } + let payableEndpoints = await privatePayableEndpoints(from: endpoints, publicKey: normalizedKey) + return !payableEndpoints.isEmpty + } + + func cachedPrivatePaymentResult(publicKey: String) async -> PublicPaykitPaymentLaunchResult { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + return .noEndpoint + } + + let cachedEntries = state.contacts[normalizedKey]?.remoteEndpoints ?? [] + let endpoints = cachedEntries.compactMap { + PublicPaykitService.parseEndpoint(methodId: $0.methodId, endpointData: $0.endpointData) + } + let payableEndpoints = await privatePayableEndpoints(from: endpoints, publicKey: normalizedKey) + + guard !payableEndpoints.isEmpty else { + return cachedEntries.isEmpty ? .noEndpoint : .notOpened + } + + return .opened(paymentRequest: PublicPaykitService.paymentRequest(from: payableEndpoints)) + } + + func contactPublicKey(forPrivateInvoicePaymentHash paymentHash: String) -> String? { + guard !paymentHash.isEmpty else { return nil } + + return state.contacts.first { _, contactState in + contactState.localInvoice?.paymentHash == paymentHash || + contactState.receivedInvoicePaymentHashes.contains(paymentHash) + }?.key + } + + func resolveSavedContactPayableEndpoint(publicKey: String, wallet: WalletViewModel) async -> Bool { + guard let normalizedKey = knownSavedContact(publicKey) else { + return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)) == true + } + + return await resolvePayableEndpoint(publicKey: normalizedKey, wallet: wallet) + } + + func resolvePayableEndpoint(publicKey: String, wallet: WalletViewModel) async -> Bool { + let generation = stateGeneration + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) else { + return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey)) == true + } + + let hadCachedPrivateEndpoint = await hasCachedPrivateEndpoint(publicKey: normalizedKey) + + do { + guard let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: 3, generation: generation) else { + if hadCachedPrivateEndpoint { + return true + } + return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: normalizedKey)) == true + } + + if state.contacts[normalizedKey]?.lastLocalPayloadHash == nil { + try await publishLocalEndpointsBestEffort( + to: normalizedKey, + linkId: linkId, + wallet: wallet, + generation: generation, + context: "resolve", + fetchedRemoteCount: 0 + ) + } + + let fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) + let publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?? linkId + try await publishLocalEndpointsBestEffort( + to: normalizedKey, + linkId: publishLinkId, + wallet: wallet, + generation: generation, + context: "resolve", + fetchedRemoteCount: fetchedCount + ) + + if await hasCachedPrivateEndpoint(publicKey: normalizedKey) { + return true + } + } catch { + Logger.warn( + "Failed to resolve private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(normalizedKey)): \(error)", + context: "PrivatePaykit" + ) + if hadCachedPrivateEndpoint { + if shouldCountAsStaleLinkFailure(error) { + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } + return true + } + } + + return await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: normalizedKey)) == true + } + + func beginSavedContactPayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { + guard let normalizedKey = knownSavedContact(publicKey) else { + return try await PublicPaykitService.beginPayment(to: publicKey) + } + + do { + let privateResult = try await beginPrivatePayment( + to: normalizedKey, + wallet: wallet + ) + if case .opened = privateResult { + return privateResult + } + } catch is CancellationError { + throw CancellationError() + } catch { + Logger.warn( + "Falling back to public Paykit for \(PubkyPublicKeyFormat.redacted(normalizedKey)) after private payment failed: \(error)", + context: "PrivatePaykit" + ) + } + + return try await PublicPaykitService.beginPayment(to: publicKey) + } + + func beginPrivatePayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { + let generation = stateGeneration + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + let linkId = try await establishedLinkId(for: normalizedKey, maxAdvanceSteps: 5, generation: generation) + else { + throw PrivatePaykitError.privateUnavailable + } + + if state.contacts[normalizedKey]?.lastLocalPayloadHash == nil { + try await publishLocalEndpointsBestEffort( + to: normalizedKey, + linkId: linkId, + wallet: wallet, + generation: generation, + context: "payment", + fetchedRemoteCount: 0 + ) + } + + var fetchedCount = 0 + var staleFetchError: Error? + do { + fetchedCount = try await fetchRemoteEndpoints(publicKey: normalizedKey, linkId: linkId, generation: generation) + } catch { + try Task.checkCancellation() + if shouldCountAsStaleLinkFailure(error) { + Logger.warn( + "Private Paykit link is stale for \(PubkyPublicKeyFormat.redacted(normalizedKey)); using cached private endpoints if available while recovery retries: \(error)", + context: "PrivatePaykit" + ) + staleFetchError = error + schedulePendingPublicationRetry(for: normalizedKey, wallet: wallet) + } else { + Logger.warn( + "Failed to refresh private Paykit endpoints for \(PubkyPublicKeyFormat.redacted(normalizedKey)); using cached private endpoints if available: \(error)", + context: "PrivatePaykit" + ) + } + } + + if staleFetchError == nil { + let publishLinkId = activeHandlesByContact[normalizedKey]?.linkId ?? linkId + try await publishLocalEndpointsBestEffort( + to: normalizedKey, + linkId: publishLinkId, + wallet: wallet, + generation: generation, + context: "payment", + fetchedRemoteCount: fetchedCount + ) + } + + let cachedResult = await cachedPrivatePaymentResult(publicKey: normalizedKey) + if case .opened = cachedResult { + return cachedResult + } + + if let staleFetchError { + throw staleFetchError + } + + return cachedResult + } + + func privatePayableEndpoints(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) async -> [PublicPaykitService.Endpoint] { + let payableEndpoints = await PublicPaykitService.payableEndpoints(from: endpoints) + var reusableEndpoints: [PublicPaykitService.Endpoint] = [] + var staleLightningPaymentHashes = Set() + + for endpoint in payableEndpoints { + if endpoint.methodId == .bitcoinLightningBolt11 { + guard let paymentHash = await paymentHash(forBolt11: endpoint.value) else { + continue + } + + if await hasAttemptedOutboundBolt11Payment(paymentHash: paymentHash) { + staleLightningPaymentHashes.insert(paymentHash) + Logger.warn( + "Ignoring already-attempted private Paykit Lightning endpoint from \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) + continue + } + + reusableEndpoints.append(endpoint) + continue + } + + guard PublicPaykitService.MethodId.onchainPreferenceOrder.contains(endpoint.methodId) else { + reusableEndpoints.append(endpoint) + continue + } + + do { + let isUsed = try await CoreService.shared.utility.isAddressUsed(address: endpoint.value) + guard !isUsed else { + Logger.warn( + "Ignoring used private Paykit on-chain endpoint from \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) + continue + } + reusableEndpoints.append(endpoint) + } catch { + Logger.warn( + "Failed to verify private Paykit on-chain endpoint usage for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", + context: "PrivatePaykit" + ) + } + } + + if !staleLightningPaymentHashes.isEmpty { + await discardRemoteLightningEndpoints(publicKey: publicKey, paymentHashes: staleLightningPaymentHashes) + } + + return reusableEndpoints + } + + @discardableResult + func fetchRemoteEndpoints(publicKey: String, linkId: String, generation: UInt64) async throws -> Int { + do { + return try await readRemoteEndpoints(publicKey: publicKey, linkId: linkId, generation: generation) + } catch { + try Task.checkCancellation() + if shouldCountAsStaleLinkFailure(error), + let restoredLinkId = try? await restoreLinkHandleForReadRetry(publicKey: publicKey, generation: generation) + { + do { + Logger.info( + "Retrying private Paykit endpoint fetch after restoring link snapshot for \(PubkyPublicKeyFormat.redacted(publicKey))", + context: "PrivatePaykit" + ) + return try await readRemoteEndpoints(publicKey: publicKey, linkId: restoredLinkId, generation: generation) + } catch { + await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) + throw error + } + } + + await recordLinkFailure(publicKey: publicKey, error: error, generation: generation) + throw error + } + } + + @discardableResult + func readRemoteEndpoints(publicKey: String, linkId: String, generation: UInt64) async throws -> Int { + let remoteEntries = try await PubkyService.getPrivatePayments(linkId: linkId) + try ensureCurrentGeneration(generation) + recordLinkSuccess(publicKey: publicKey) + try await persistLinkSnapshot(linkId: linkId, publicKey: publicKey, generation: generation) + try ensureCurrentGeneration(generation) + + guard !remoteEntries.isEmpty else { + // Paykit returns an empty map when there are no unread private-payment messages. + // Keep the cached map in that case; the current rc5 API cannot distinguish + // "no unread update" from a peer intentionally publishing an empty map. + return 0 + } + + state.contacts[publicKey, default: ContactState()].remoteEndpoints = remoteEntries.map(StoredPaymentEntry.init(entry:)) + persistState(markWalletBackup: true) + return remoteEntries.count + } + + func discardRemoteLightningEndpoints(publicKey: String, paymentHashes: Set) async { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + var contactState = state.contacts[normalizedKey], + !paymentHashes.isEmpty + else { return } + + var filteredEntries: [StoredPaymentEntry] = [] + var didRemoveEndpoint = false + + for entry in contactState.remoteEndpoints { + guard entry.methodId == PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + let endpoint = PublicPaykitService.parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData), + let paymentHash = await paymentHash(forBolt11: endpoint.value), + paymentHashes.contains(paymentHash) + else { + filteredEntries.append(entry) + continue + } + + didRemoveEndpoint = true + } + + guard didRemoveEndpoint else { return } + + contactState.remoteEndpoints = filteredEntries + state.contacts[normalizedKey] = contactState + persistState(markWalletBackup: true) + } + + func discardRemoteOnchainEndpoints(publicKey: String, addresses: Set) async { + guard let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey), + var contactState = state.contacts[normalizedKey], + !addresses.isEmpty + else { return } + + let previousCount = contactState.remoteEndpoints.count + contactState.remoteEndpoints = contactState.remoteEndpoints.filter { entry in + guard PublicPaykitService.MethodId.onchainPreferenceOrder.contains(where: { $0.rawValue == entry.methodId }), + let endpoint = PublicPaykitService.parseEndpoint(methodId: entry.methodId, endpointData: entry.endpointData) + else { + return true + } + + return !addresses.contains(endpoint.value) + } + + guard contactState.remoteEndpoints.count != previousCount else { return } + + state.contacts[normalizedKey] = contactState + persistState(markWalletBackup: true) + } +} diff --git a/Bitkit/Services/PrivatePaykitService+State.swift b/Bitkit/Services/PrivatePaykitService+State.swift new file mode 100644 index 00000000..bfb91bd2 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService+State.swift @@ -0,0 +1,325 @@ +import Foundation + +// MARK: - Active Paykit Handles + +extension PrivatePaykitService { + func closeAndClear() async { + resetInFlightWork() + await closeActivePaykitHandles() + activeHandlesByContact.removeAll() + knownSavedContactKeys.removeAll() + state = PrivatePaykitState(contacts: [:]) + try? Keychain.delete(key: .privatePaykitSecretState) + UserDefaults.standard.removeObject(forKey: Self.cacheStateKey) + markWalletBackupDataChanged() + } + + func persistLinkSnapshot(linkId: String, publicKey: String, generation: UInt64, linkWasReplaced: Bool = false) async throws { + let snapshotHex = try await PubkyService.serializeEncryptedLink(linkId: linkId) + try ensureCurrentGeneration(generation) + guard activeHandlesByContact[publicKey]?.linkId == linkId else { + throw PrivatePaykitError.staleLinkState + } + let completedAttemptId = state.contacts[publicKey]?.mainRecoveryAttemptId ?? state.contacts[publicKey]?.responderRecoveryAttemptId + state.contacts[publicKey, default: ContactState()].linkSnapshotHex = snapshotHex + state.contacts[publicKey]?.handshakeSnapshotHex = nil + state.contacts[publicKey]?.recoveryStartedAt = nil + state.contacts[publicKey]?.mainRecoveryAttemptId = nil + state.contacts[publicKey]?.responderRecoveryAttemptId = nil + if linkWasReplaced || state.contacts[publicKey]?.linkCompletedAt == nil { + state.contacts[publicKey]?.linkCompletedAt = UInt64(Date().timeIntervalSince1970) + } + if linkWasReplaced { + state.contacts[publicKey]?.lastLocalPayloadHash = nil + } + if let completedAttemptId { + state.contacts[publicKey]?.lastCompletedRecoveryAttemptId = completedAttemptId + } + persistState(markWalletBackup: true) + } + + func clearContactState(publicKey: String) async { + let ownPublicKey = await (PubkyService.currentPublicKey()).flatMap(PubkyPublicKeyFormat.normalized) + if let ownPublicKey { + await clearRecoveryMarker(from: ownPublicKey, to: publicKey) + } + + if let linkId = activeHandlesByContact[publicKey]?.linkId { + try? await PubkyService.closeEncryptedLink(linkId: linkId) + } + if let handshakeId = activeHandlesByContact[publicKey]?.handshakeId { + try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) + } + + activeHandlesByContact[publicKey] = nil + state.contacts[publicKey] = nil + persistState(markWalletBackup: true) + } + + func closeActivePaykitHandles() async { + for handles in activeHandlesByContact.values { + if let linkId = handles.linkId { + try? await PubkyService.closeEncryptedLink(linkId: linkId) + } + if let handshakeId = handles.handshakeId { + try? await PubkyService.dropEncryptedLinkHandshake(handshakeId: handshakeId) + } + } + } + + func recordLinkSuccess(publicKey: String) { + guard state.contacts[publicKey]?.linkFailureCount != 0 else { return } + state.contacts[publicKey]?.linkFailureCount = 0 + persistState() + } + + func recordLinkFailure(publicKey: String, error: Error, generation: UInt64) async { + guard stateGeneration == generation, !Task.isCancelled else { + return + } + + guard shouldCountAsStaleLinkFailure(error) else { + return + } + + let failureCount = (state.contacts[publicKey]?.linkFailureCount ?? 0) + 1 + state.contacts[publicKey, default: ContactState()].linkFailureCount = failureCount + + guard failureCount >= Self.staleLinkFailureThreshold else { + persistState() + return + } + + if let linkId = activeHandlesByContact[publicKey]?.linkId { + try? await PubkyService.closeEncryptedLink(linkId: linkId) + } + guard stateGeneration == generation, !Task.isCancelled else { + return + } + + stateGeneration &+= 1 + linkEstablishmentTasks.removeValue(forKey: publicKey)?.task.cancel() + if var handles = activeHandlesByContact[publicKey] { + handles.linkId = nil + handles.handshakeId = nil + activeHandlesByContact[publicKey] = handles + } + state.contacts[publicKey]?.linkSnapshotHex = nil + state.contacts[publicKey]?.handshakeSnapshotHex = nil + state.contacts[publicKey]?.lastLocalPayloadHash = nil + state.contacts[publicKey]?.remoteEndpoints = [] + state.contacts[publicKey]?.linkFailureCount = 0 + state.contacts[publicKey]?.recoveryStartedAt = UInt64(Date().timeIntervalSince1970) + state.contacts[publicKey]?.mainRecoveryAttemptId = nil + state.contacts[publicKey]?.responderRecoveryAttemptId = nil + persistState(markWalletBackup: true) + } + + func resetInFlightWork() { + stateGeneration &+= 1 + for inFlight in linkEstablishmentTasks.values { + inFlight.task.cancel() + } + linkEstablishmentTasks.removeAll() + for inFlight in publicationTasks.values { + inFlight.task.cancel() + } + publicationTasks.removeAll() + for inFlight in pendingPublicationRetryTasks.values { + inFlight.cancel() + } + pendingPublicationRetryTasks.removeAll() + } + + func invalidateLinkEstablishmentWork() { + stateGeneration &+= 1 + for inFlight in linkEstablishmentTasks.values { + inFlight.task.cancel() + } + linkEstablishmentTasks.removeAll() + } + + func invalidateLinkEstablishment(for publicKey: String) { + stateGeneration &+= 1 + if let inFlight = linkEstablishmentTasks.removeValue(forKey: publicKey) { + inFlight.task.cancel() + } + cancelPendingPublicationRetry(for: publicKey) + } + + func ensureCurrentGeneration(_ generation: UInt64) throws { + try Task.checkCancellation() + guard stateGeneration == generation else { + throw PrivatePaykitError.privateUnavailable + } + } + + func persistState(markWalletBackup: Bool = false) { + do { + let secretState = state.secretState + if secretState.contacts.isEmpty { + try? Keychain.delete(key: .privatePaykitSecretState) + } else { + let data = try JSONEncoder().encode(secretState) + try Keychain.upsert(key: .privatePaykitSecretState, data: data) + } + + let cacheState = state.cacheState + if cacheState.contacts.isEmpty { + UserDefaults.standard.removeObject(forKey: Self.cacheStateKey) + } else { + let data = try JSONEncoder().encode(cacheState) + UserDefaults.standard.set(data, forKey: Self.cacheStateKey) + } + + if markWalletBackup { + markWalletBackupDataChanged() + } + } catch { + Logger.error("Failed to persist private Paykit state: \(error)", context: "PrivatePaykit") + } + } + + func markWalletBackupDataChanged() { + Self.walletBackupDataChangedSubject.send() + } + + @discardableResult + func purgePrivatePaymentOutbox(for publicKey: String, reason: String) async -> Bool { + let otherContactCount = state.contacts.keys.filter { $0 != publicKey }.count + guard otherContactCount == 0 else { + Logger.warn( + "Skipping broad private Paykit transport cleanup during \(reason) because \(otherContactCount) other private contact(s) have state; continuing recovery without purge", + context: "PrivatePaykit" + ) + return true + } + + guard let sessionSecret = try? Keychain.loadString(key: .paykitSession), + !sessionSecret.isEmpty + else { return false } + + do { + if try await deletePrivatePaymentStorageRoot(sessionSecret: sessionSecret, reason: reason) { + return true + } + + let result = try await purgePrivatePaymentStorageTree( + sessionSecret: sessionSecret, + dirPath: Self.privateStorageRootPath, + depth: 0, + deletedSoFar: 0 + ) + if result.deletedCount > 0 { + Logger.info("Cleared \(result.deletedCount) stale private Paykit transport messages during \(reason)", context: "PrivatePaykit") + } + if result.didHitLimit { + Logger.warn("Stopped private Paykit transport cleanup after reaching the safety limit", context: "PrivatePaykit") + } + return !result.didHitLimit && !result.didFail + } catch { + if isMissingPrivateStorageError(error) { + return true + } + Logger.warn("Failed to clear private Paykit transport messages during \(reason): \(error)", context: "PrivatePaykit") + return false + } + } + + func deletePrivatePaymentStorageRoot(sessionSecret: String, reason: String) async throws -> Bool { + do { + try await PubkyService.sessionDelete(sessionSecret: sessionSecret, path: filePath(Self.privateStorageRootPath)) + Logger.info("Cleared stale private Paykit transport directory during \(reason)", context: "PrivatePaykit") + return true + } catch { + return false + } + } + + func purgePrivatePaymentStorageTree(sessionSecret: String, dirPath: String, depth: Int, + deletedSoFar: Int) async throws -> PrivateStoragePurgeResult + { + guard deletedSoFar < Self.privateStoragePurgeMaxEntries else { + return PrivateStoragePurgeResult(deletedCount: 0, didHitLimit: true, didFail: false) + } + guard depth < Self.privateStoragePurgeMaxDepth else { + return PrivateStoragePurgeResult(deletedCount: 0, didHitLimit: true, didFail: false) + } + + let entries = try await PubkyService.sessionList(sessionSecret: sessionSecret, dirPath: directoryPath(dirPath)) + var deletedCount = 0 + var didHitLimit = false + var didFail = false + + for entry in entries { + guard deletedSoFar + deletedCount < Self.privateStoragePurgeMaxEntries else { + didHitLimit = true + break + } + guard let path = privateStoragePath(from: entry) else { continue } + + do { + try await PubkyService.sessionDelete(sessionSecret: sessionSecret, path: filePath(path)) + deletedCount += 1 + } catch { + if depth == 0, !path.hasSuffix("/") { + do { + let childResult = try await purgePrivatePaymentStorageTree( + sessionSecret: sessionSecret, + dirPath: directoryPath(path), + depth: depth + 1, + deletedSoFar: deletedSoFar + deletedCount + ) + deletedCount += childResult.deletedCount + didHitLimit = didHitLimit || childResult.didHitLimit + didFail = didFail || childResult.didFail + continue + } catch { + if isMissingPrivateStorageError(error) { + continue + } + Logger.warn("Failed to list private Paykit transport directory at \(path): \(error)", context: "PrivatePaykit") + didFail = true + } + } else if isMissingPrivateStorageError(error) { + continue + } + + Logger.warn("Failed to delete stale private Paykit transport entry at \(path): \(error)", context: "PrivatePaykit") + didFail = true + } + } + + return PrivateStoragePurgeResult(deletedCount: deletedCount, didHitLimit: didHitLimit, didFail: didFail) + } + + func privateStoragePath(from entry: String) -> String? { + let path: String = if let url = URL(string: entry), url.scheme == "pubky" { + url.path + } else { + entry + } + + guard path.hasPrefix(Self.privateStorageRootPath) else { return nil } + return path + } + + func directoryPath(_ path: String) -> String { + path.hasSuffix("/") ? path : "\(path)/" + } + + func filePath(_ path: String) -> String { + path.hasSuffix("/") ? String(path.dropLast()) : path + } + + func isMissingPrivateStorageError(_ error: Error) -> Bool { + let reason: String = if let appError = error as? AppError { + [appError.message, appError.debugMessage].compactMap { $0 }.joined(separator: " ") + } else { + error.localizedDescription + } + + let lowercasedReason = reason.lowercased() + return lowercasedReason.contains("404") && lowercasedReason.contains("not found") + } +} diff --git a/Bitkit/Services/PrivatePaykitService.swift b/Bitkit/Services/PrivatePaykitService.swift new file mode 100644 index 00000000..1ddcd1e5 --- /dev/null +++ b/Bitkit/Services/PrivatePaykitService.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation + +// MARK: - Core Actor + +actor PrivatePaykitService { + static let shared = PrivatePaykitService() + + static let walletBackupDataChangedSubject = PassthroughSubject() + + nonisolated static var walletBackupDataChangedPublisher: AnyPublisher { + walletBackupDataChangedSubject.eraseToAnyPublisher() + } + + static let maxNoisePayloadBytes = 1000 + static let invoiceRefreshBufferSeconds: TimeInterval = 30 * 60 + static let maxReceivedInvoicePaymentHashesPerContact = 100 + static let staleLinkFailureThreshold = 3 + static let publishingEnabledKey = "sharesPublicPaykitEndpoints" + static let cleanupPendingKey = "paykitContactSharingCleanupPending" + static let cacheStateKey = "privatePaykitCacheState" + static let privateEndpointRemovalPayload = #"{"value":""}"# + static let recoveryMarkerStageInit = "init" + static let recoveryMarkerStageResponse = "response" + static let recoveryMarkerStageFinal = "final" + static let pendingPublicationRetryDelay: UInt64 = 5_000_000_000 + static let pendingPublicationRetryAttempts = 60 + static let freshLinkInitialPublishDelaySeconds: UInt64 = 8 + static let completedLinkRecoveryMarkerGraceSeconds: UInt64 = 5 * 60 + static let privateStorageRootPath = "/pub/paykit/v0/private/" + static let privateStoragePurgeMaxEntries = 500 + static let privateStoragePurgeMaxDepth = 3 + + var state: PrivatePaykitState + var activeHandlesByContact: [String: ContactPaykitHandles] = [:] + var linkEstablishmentTasks: [String: LinkEstablishmentTask] = [:] + var publicationTasks: [String: PublicationTask] = [:] + var pendingPublicationRetryTasks: [String: Task] = [:] + var knownSavedContactKeys: Set = [] + var stateGeneration: UInt64 = 0 + + init() { + let secretState = (try? Keychain.load(key: .privatePaykitSecretState)) + .flatMap { try? JSONDecoder().decode(PrivatePaykitSecretState.self, from: $0) } ?? PrivatePaykitSecretState(contacts: [:]) + let cacheState = UserDefaults.standard.data(forKey: Self.cacheStateKey) + .flatMap { try? JSONDecoder().decode(PrivatePaykitCacheState.self, from: $0) } ?? PrivatePaykitCacheState(contacts: [:]) + + state = PrivatePaykitState(secretState: secretState, cacheState: cacheState) + } + + static func shouldInitiate(ownPublicKey: String, remotePublicKey: String) -> Bool { + let own = PubkyPublicKeyFormat.normalized(ownPublicKey) ?? ownPublicKey + let remote = PubkyPublicKeyFormat.normalized(remotePublicKey) ?? remotePublicKey + return own > remote + } + + static func setContactSharingCleanupPending(_ isPending: Bool) { + UserDefaults.standard.set(isPending, forKey: cleanupPendingKey) + } +} diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 443af1c5..2b915160 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -218,6 +218,86 @@ enum PubkyService { } } + // MARK: - Private Payments + + static func initiateEncryptedLink(secretKeyHex: String, receiverPublicKey: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitInitiateEncryptedLink(secretKeyHex: secretKeyHex, receiverPublicKey: receiverPublicKey) + } + } + + static func acceptEncryptedLink(secretKeyHex: String, senderPublicKey: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitAcceptEncryptedLink(secretKeyHex: secretKeyHex, senderPublicKey: senderPublicKey) + } + } + + static func advanceHandshake(handshakeId: String) async throws -> FfiHandshakeProgress { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitAdvanceHandshake(handshakeId: handshakeId) + } + } + + static func restoreEncryptedLink(secretKeyHex: String, snapshotHex: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitRestoreEncryptedLink(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + } + } + + static func encryptedLinkSnapshotRecipient(snapshotHex: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try paykitEncryptedLinkSnapshotRecipient(snapshotHex: snapshotHex) + } + } + + static func restoreEncryptedLinkHandshake(secretKeyHex: String, snapshotHex: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitRestoreEncryptedLinkHandshake(secretKeyHex: secretKeyHex, snapshotHex: snapshotHex) + } + } + + static func encryptedLinkHandshakeSnapshotRecipient(snapshotHex: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try paykitEncryptedLinkHandshakeSnapshotRecipient(snapshotHex: snapshotHex) + } + } + + static func serializeEncryptedLink(linkId: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitSerializeEncryptedLink(linkId: linkId) + } + } + + static func serializeEncryptedLinkHandshake(handshakeId: String) async throws -> String { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitSerializeEncryptedLinkHandshake(handshakeId: handshakeId) + } + } + + static func closeEncryptedLink(linkId: String) async throws { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitCloseEncryptedLink(linkId: linkId) + } + } + + static func dropEncryptedLinkHandshake(handshakeId: String) async throws { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitDropEncryptedLinkHandshake(handshakeId: handshakeId) + } + } + + static func setPrivatePayments(linkId: String, entries: [FfiPaymentEntry]) async throws { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitSetPrivatePayments(linkId: linkId, entries: entries) + } + } + + static func getPrivatePayments(linkId: String) async throws -> [FfiPaymentEntry] { + try await ServiceQueue.background(.core, wrapErrors: false) { + try await paykitGetPrivatePayments(linkId: linkId) + } + } + // MARK: - Sign Out static func signOut() async throws { diff --git a/Bitkit/Services/RNBackupClient.swift b/Bitkit/Services/RNBackupClient.swift index 1eb90b57..245dd1a7 100644 --- a/Bitkit/Services/RNBackupClient.swift +++ b/Bitkit/Services/RNBackupClient.swift @@ -78,12 +78,7 @@ class RNBackupClient { } private func networkString() -> String { - switch Env.network { - case .bitcoin: "bitcoin" - case .testnet: "testnet" - case .regtest: "regtest" - case .signet: "signet" - } + Env.networkName } // MARK: - Public API diff --git a/Bitkit/Services/ServiceQueue.swift b/Bitkit/Services/ServiceQueue.swift index 59b3b66a..ffd6ceeb 100644 --- a/Bitkit/Services/ServiceQueue.swift +++ b/Bitkit/Services/ServiceQueue.swift @@ -39,7 +39,9 @@ class ServiceQueue { /// - blocking: The function to run /// - functionName: The name of the function for logging /// - Returns: The result of the blocking function - static func background(_ service: ServiceTypes, _ blocking: @escaping () throws -> T, functionName: String = #function) async throws -> T { + static func background(_ service: ServiceTypes, wrapErrors: Bool = true, _ blocking: @escaping () throws -> T, + functionName: String = #function) async throws -> T + { let startTime = CFAbsoluteTimeGetCurrent() let result = try await withCheckedThrowingContinuation { continuation in service.queue.async { @@ -47,9 +49,7 @@ class ServiceQueue { let res = try blocking() continuation.resume(with: .success(res)) } catch { - let appError = AppError(error: error) - Logger.error("\(appError.message) [\(appError.debugMessage ?? "")]", context: "ServiceQueue: \(service)") - continuation.resume(throwing: appError) + continuation.resume(throwing: queueError(from: error, service: service, wrapErrors: wrapErrors)) } } } @@ -66,7 +66,7 @@ class ServiceQueue { /// - execute: The function /// - functionName: The name of the function for logging /// - Returns: The result of the async function - static func background(_ service: ServiceTypes, _ execute: @escaping () async throws -> T, + static func background(_ service: ServiceTypes, wrapErrors: Bool = true, _ execute: @escaping () async throws -> T, functionName: String = #function) async throws -> T { let startTime = CFAbsoluteTimeGetCurrent() @@ -78,9 +78,7 @@ class ServiceQueue { let result = try await execute() continuation.resume(returning: result) } catch { - let appError = AppError(error: error) - Logger.error("\(appError.message) [\(appError.debugMessage ?? "")]", context: "ServiceQueue: \(service)") - continuation.resume(throwing: appError) + continuation.resume(throwing: queueError(from: error, service: service, wrapErrors: wrapErrors)) } let timeElapsed = Double(round(100 * (CFAbsoluteTimeGetCurrent() - startTime)) / 100) @@ -90,6 +88,17 @@ class ServiceQueue { } } + private static func queueError(from error: Error, service: ServiceTypes, wrapErrors: Bool) -> Error { + guard wrapErrors else { + Logger.error(error.localizedDescription, context: "ServiceQueue: \(service)") + return error + } + + let appError = AppError(error: error) + Logger.error("\(appError.message) [\(appError.debugMessage ?? "")]", context: "ServiceQueue: \(service)") + return appError + } + /// Executes a function on chosen service queue with completion handler /// - Parameters: /// - service: Queue to run on diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index a93fc784..04d596a5 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -10,6 +10,7 @@ enum AppReset { toastType: Toast.ToastType = .success ) async throws { await PubkyProfileManager.removePublicPaykitEndpointsBestEffort(context: "AppReset.wipe") + await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "AppReset.wipe") // Set wiping flag to prevent backups during wipe operations BackupService.shared.setWiping(true) @@ -31,6 +32,7 @@ enum AppReset { // Clear any live Pubky runtime state and cached profile images. await PubkyProfileManager.clearLocalState() + await PrivatePaykitAddressReservationStore.shared.clear() // Wipe keychain try Keychain.wipeEntireKeychain() diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 0bd80574..25e2e187 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -7,6 +7,7 @@ enum KeychainEntryType { case pushNotificationPrivateKey // For secp256k1 shared secret when decrypting push payload case securityPin case paykitSession + case privatePaykitSecretState case pubkySecretKey var storageKey: String { @@ -16,6 +17,7 @@ enum KeychainEntryType { case .pushNotificationPrivateKey: "push_notification_private_key" case .securityPin: "security_pin" case .paykitSession: "paykit_session" + case .privatePaykitSecretState: "private_paykit_secret_state" case .pubkySecretKey: "pubky_secret_key" } } diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 74dcb204..eab39854 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -217,7 +217,7 @@ class ActivityListViewModel: ObservableObject { return } - if let ldkPayments = lightningService.payments { + if let ldkPayments = await lightningService.listPayments() { isSyncingLdkNodePayments = true do { try await coreService.activity.syncLdkNodePayments(ldkPayments) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index ca1e430d..8d43f047 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -970,7 +970,7 @@ extension AppViewModel { if MigrationsService.shared.needsPostMigrationSync { Task { @MainActor in - try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.payments ?? []) + try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? []) await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() await MigrationsService.shared.reapplyMetadataAfterSync() diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 86042849..b59fa757 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -366,6 +366,9 @@ class SettingsViewModel: NSObject, ObservableObject { } wallet?.syncState() + if !enabled, let wallet { + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints(wallet: wallet, reason: "address type monitoring changed") + } return true } @@ -509,7 +512,7 @@ class SettingsViewModel: NSObject, ObservableObject { private func generateAndUpdateAddress(addressType: AddressScriptType, wallet: WalletViewModel?) async { do { - let newAddress = try await lightningService.newAddressForType(addressType) + let newAddress = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) guard addressType.matchesAddressFormat(newAddress, network: Env.network) else { Logger.error("Generated address did not match expected format for \(addressType.stringValue): \(newAddress)") return diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 59850d2e..47429cdf 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -62,6 +62,7 @@ class WalletViewModel: ObservableObject { @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false private static let publicPaykitInvoiceRefreshBufferSeconds: TimeInterval = 30 * 60 + private static let paykitChannelUsabilityRefreshDelay: UInt64 = 5_000_000_000 private let lightningService: LightningService private let coreService: CoreService @@ -210,6 +211,7 @@ class WalletViewModel: ObservableObject { self.bolt11 = "" self.rotatePublicPaykitInvoiceIfNeeded(paymentHash: paymentHash) Task { + await PrivatePaykitService.shared.handleReceivedPayment(paymentHash: paymentHash, wallet: self) await self.refreshAndSyncState() try? await self.refreshBip21() } @@ -217,8 +219,13 @@ class WalletViewModel: ObservableObject { self.bolt11 = "" Task { await self.reconnectTrustedPeers() - await self.refreshAndSyncState() - try? await self.refreshBip21() + await self.refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: "channel-ready refresh") + try? await Task.sleep(nanoseconds: Self.paykitChannelUsabilityRefreshDelay) + guard !Task.isCancelled else { return } + await self.refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason: "channel-ready delayed refresh", + forceRefreshLightning: true + ) } case let .channelClosed(channelId, _, _, reason): @@ -227,14 +234,33 @@ class WalletViewModel: ObservableObject { await self.refreshAndSyncState() await self.handleChannelClosed(channelId: channelId, reason: reason) try? await self.refreshBip21() + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints(wallet: self, reason: "channel-closed refresh") } // MARK: Onchain Transaction Events - case .onchainTransactionReceived, .onchainTransactionConfirmed, .onchainTransactionReplaced, .onchainTransactionReorged, - .onchainTransactionEvicted: + case let .onchainTransactionReceived(_, details): + Task { + await self.refreshAndSyncState() + await PrivatePaykitService.shared.handleOnchainActivity( + receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), + wallet: self + ) + } + + case let .onchainTransactionConfirmed(_, _, _, _, details): + Task { + await self.refreshAndSyncState() + await PrivatePaykitService.shared.handleOnchainActivity( + receivedAddresses: details.outputs.compactMap(\.scriptpubkeyAddress), + wallet: self + ) + } + + case .onchainTransactionReplaced, .onchainTransactionReorged, .onchainTransactionEvicted: Task { await self.refreshAndSyncState() + await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) } // MARK: Sync Events @@ -453,6 +479,8 @@ class WalletViewModel: ObservableObject { isSyncingWallet = false syncState() + await PrivatePaykitService.shared.reconcileReceivedPayments(wallet: self) + await PrivatePaykitService.shared.handleOnchainActivity(wallet: self) } /// Sends bitcoin to an on-chain address @@ -871,9 +899,12 @@ class WalletViewModel: ObservableObject { channelCount = channels.count } - if sharesPublicPaykitEndpoints, hasUsableChannels, !hadUsableChannels { + if hasUsableChannels, !hadUsableChannels { Task { [weak self] in - await self?.syncPublicPaykitEndpointsAfterChannelBecameUsable() + await self?.refreshPaykitEndpointsAfterChannelAvailabilityChanged( + reason: "channel-usable refresh", + forceRefreshLightning: true + ) } } } @@ -952,15 +983,21 @@ class WalletViewModel: ObservableObject { @discardableResult private func refreshReusableOnchainAddress() async throws -> String { + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + + if await PrivatePaykitAddressReservationStore.shared.isUnavailableForReusableReceive(address: onchainAddress) { + onchainAddress = "" + } + if onchainAddress.isEmpty { - onchainAddress = try await lightningService.newAddress() + onchainAddress = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) } else { // Check if current address has been used let hasTransactions = try await coreService.utility.isAddressUsed(address: onchainAddress) if hasTransactions { // Address has been used, generate a new one - onchainAddress = try await lightningService.newAddress() + onchainAddress = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) } } @@ -1001,6 +1038,21 @@ class WalletViewModel: ObservableObject { } } + private func refreshPaykitEndpointsAfterChannelAvailabilityChanged(reason: String, forceRefreshLightning: Bool = false) async { + await refreshAndSyncState() + try? await refreshBip21(forceRefreshBolt11: forceRefreshLightning) + + if sharesPublicPaykitEndpoints, hasUsableChannels { + await syncPublicPaykitEndpointsAfterChannelBecameUsable() + } + + await PrivatePaykitService.shared.refreshKnownSavedContactEndpoints( + wallet: self, + reason: reason, + forceRefreshLightning: forceRefreshLightning + ) + } + private func hasReusablePublicPaykitInvoice() async -> Bool { guard !publicPaykitBolt11.isEmpty else { return false } guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index b88f7860..8fc403e4 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -8,15 +8,16 @@ struct AddContactView: View { @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var wallet: WalletViewModel 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? @State private var canRetryError = true + @State private var hasPayableEndpoint = false private var truncatedPublicKey: String { let displayKey = normalizedPublicKey ?? publicKey @@ -141,7 +142,7 @@ struct AddContactView: View { .padding(.horizontal, 32) .padding(.bottom, 16) - if hasPublicPaymentEndpoint { + if hasPayableEndpoint { CustomButton(title: t("wallet__send"), variant: .secondary) { await payContact() } @@ -200,6 +201,7 @@ struct AddContactView: View { fetchedProfile = nil errorMessage = nil canRetryError = true + hasPayableEndpoint = false switch resolveAddContactValidation( input: publicKey, @@ -224,10 +226,10 @@ struct AddContactView: View { case let .valid(normalizedKey): if let profile = await contactsManager.fetchContactProfile(publicKey: normalizedKey, includePlaceholder: true) { fetchedProfile = profile + hasPayableEndpoint = await (try? PublicPaykitService.hasPayablePublicEndpoint(publicKey: normalizedKey)) == true } else { errorMessage = t("contacts__add_error") } - await loadPaymentEndpoints(publicKey: normalizedKey) } isLoading = false @@ -258,18 +260,6 @@ struct AddContactView: View { } } - private func loadPaymentEndpoints(publicKey: String) async { - do { - hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) - } catch { - Logger.warn( - "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(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")) @@ -338,6 +328,7 @@ struct AddContactView: View { .environmentObject(PubkyProfileManager()) .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) + .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Contacts/ContactDetailView.swift b/Bitkit/Views/Contacts/ContactDetailView.swift index d96b8a28..aa6668d1 100644 --- a/Bitkit/Views/Contacts/ContactDetailView.swift +++ b/Bitkit/Views/Contacts/ContactDetailView.swift @@ -7,11 +7,11 @@ struct ContactDetailView: View { @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var wallet: WalletViewModel 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 @@ -36,8 +36,8 @@ struct ContactDetailView: View { if let cached = contactsManager.contacts.first(where: { $0.publicKey == publicKey }) { profile = cached.profile hasResolvedContactFromContacts = true + isLoading = false } - await loadPaymentEndpoints() isLoading = false } .onReceive(contactsManager.$contacts) { updatedContacts in @@ -94,14 +94,12 @@ struct ContactDetailView: View { private var contactActions: some View { HStack(spacing: 16) { - if hasPublicPaymentEndpoint { - GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) { - Task { - await payContact() - } + GradientCircleButton(icon: "coins", accessibilityLabel: t("wallet__send")) { + Task { + await payContact() } - .accessibilityIdentifier("ContactPay") } + .accessibilityIdentifier("ContactPay") GradientCircleButton(icon: "activity", accessibilityLabel: t("wallet__activity")) { navigation.navigate(.contactActivity(publicKey: publicKey)) @@ -239,7 +237,6 @@ struct ContactDetailView: View { } else if let fetched = await contactsManager.fetchContactProfile(publicKey: publicKey) { profile = fetched } - await loadPaymentEndpoints() } .accessibilityIdentifier("ContactRetry") Spacer() @@ -268,21 +265,9 @@ struct ContactDetailView: View { } } - private func loadPaymentEndpoints() async { - do { - hasPublicPaymentEndpoint = try await PublicPaykitService.hasPayablePublicEndpoint(publicKey: publicKey) - } catch { - Logger.warn( - "Failed to load public payment endpoints for \(PubkyPublicKeyFormat.redacted(publicKey)): \(error)", - context: "ContactDetailView" - ) - hasPublicPaymentEndpoint = false - } - } - private func payContact() async { do { - let result = try await PublicPaykitService.beginPayment(to: publicKey) + let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: publicKey, wallet: wallet) switch result { case let .opened(paymentRequest): @@ -341,6 +326,7 @@ struct ContactDetailView: View { .environmentObject(ContactsManager()) .environmentObject(SettingsViewModel.shared) .environmentObject(SheetViewModel()) + .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Profile/PayContactsView.swift b/Bitkit/Views/Profile/PayContactsView.swift index 7e0889ec..1653d9cb 100644 --- a/Bitkit/Views/Profile/PayContactsView.swift +++ b/Bitkit/Views/Profile/PayContactsView.swift @@ -5,6 +5,7 @@ struct PayContactsView: View { @AppStorage("sharesPublicPaykitEndpoints") private var sharesPublicPaykitEndpoints = false @EnvironmentObject var app: AppViewModel + @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel @@ -71,9 +72,39 @@ struct PayContactsView: View { defer { isSaving = false } do { - try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: publish) - sharesPublicPaykitEndpoints = publish - hasConfirmedPublicPaykitEndpoints = true + if publish { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: true) + sharesPublicPaykitEndpoints = true + hasConfirmedPublicPaykitEndpoints = true + PrivatePaykitService.setContactSharingCleanupPending(false) + await PrivatePaykitService.shared.prepareSavedContacts( + contactsManager.contacts.map(\.publicKey), + wallet: wallet + ) + } else { + var cleanupError: Error? + sharesPublicPaykitEndpoints = false + hasConfirmedPublicPaykitEndpoints = true + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: wallet, publish: false) + } catch { + cleanupError = error + Logger.warn("Failed to remove public Paykit endpoints while disabling contact payments: \(error)", context: "PayContactsView") + } + do { + try await PrivatePaykitService.shared.removePublishedEndpoints() + } catch { + if cleanupError == nil { + cleanupError = error + } + Logger.warn("Failed to remove private Paykit endpoints while disabling contact payments: \(error)", context: "PayContactsView") + } + if let cleanupError { + PrivatePaykitService.setContactSharingCleanupPending(true) + throw cleanupError + } + PrivatePaykitService.setContactSharingCleanupPending(false) + } navigation.path = [.profile] } catch { enablePayments = hasConfirmedPublicPaykitEndpoints ? sharesPublicPaykitEndpoints : true @@ -91,6 +122,7 @@ struct PayContactsView: View { NavigationStack { PayContactsView() .environmentObject(AppViewModel()) + .environmentObject(ContactsManager()) .environmentObject(NavigationViewModel()) .environmentObject(WalletViewModel()) } diff --git a/Bitkit/Views/Settings/BlocktankRegtestView.swift b/Bitkit/Views/Settings/BlocktankRegtestView.swift index 130ed77b..8a8d45aa 100644 --- a/Bitkit/Views/Settings/BlocktankRegtestView.swift +++ b/Bitkit/Views/Settings/BlocktankRegtestView.swift @@ -1,3 +1,4 @@ +import LDKNode import SwiftUI struct BlocktankRegtestScreen: View { @@ -40,7 +41,8 @@ struct BlocktankRegtestScreen: View { // Generate a fresh address when the view appears Task { do { - let newAddress = try await LightningService.shared.newAddress() + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + let newAddress = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) depositAddress = newAddress } catch { // Fallback to wallet's current address if generation fails @@ -71,7 +73,10 @@ struct BlocktankRegtestScreen: View { Button { Task { do { - let newAddress = try await LightningService.shared.newAddress() + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + let newAddress = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress( + addressType: addressType + ) depositAddress = newAddress } catch { app.toast(type: .error, title: "Failed to generate address", description: error.localizedDescription) @@ -95,7 +100,8 @@ struct BlocktankRegtestScreen: View { throw ValidationError("Invalid amount") } - let newAddress = try await LightningService.shared.newAddress() + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + let newAddress = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) Logger.debug("Generated new address for deposit: \(newAddress)", context: "BlocktankRegtestScreen") let txId = try await CoreService.shared.blocktank.regtestDepositFunds( diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index 98d14017..60c125bf 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -1,3 +1,4 @@ +import LDKNode import SwiftUI struct SpendingAmount: View { @@ -153,10 +154,9 @@ struct SpendingAmount: View { return } - let lightningService = LightningService.shared - do { - let address = try await lightningService.newAddress() + let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType")) + let address = try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType) guard let feeEstimates = await feeEstimatesManager.getEstimates(refresh: true) else { await MainActor.run { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 2e4c8532..87368d56 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -469,10 +469,24 @@ struct SendConfirmationView: View { bolt11: invoice.bolt11, sats: paymentSats, onTimeout: { + if let contactPublicKey { + Task { + await PrivatePaykitService.shared.discardRemoteLightningEndpoints( + publicKey: contactPublicKey, + paymentHashes: [paymentHash] + ) + } + } app.addPendingPaymentHash(paymentHash, contactPublicKey: contactPublicKey) navigationPath.append(.pending(paymentHash: paymentHash)) } ) + if let contactPublicKey { + await PrivatePaykitService.shared.discardRemoteLightningEndpoints( + publicKey: contactPublicKey, + paymentHashes: [paymentHash] + ) + } await syncContactForActivity(paymentId: paymentHash, contactPublicKey: contactPublicKey) Logger.info("Lightning payment successful: \(paymentHash)") navigationPath.append(.success(paymentId: paymentHash)) @@ -480,12 +494,26 @@ struct SendConfirmationView: View { // onTimeout callback already navigated to .pending; suppress throw return } catch { + if let contactPublicKey, + PrivatePaykitService.isDuplicatePaymentError(error) + { + await PrivatePaykitService.shared.discardRemoteLightningEndpoints( + publicKey: contactPublicKey, + paymentHashes: [paymentHash] + ) + } throw error } } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount) let txid = try await wallet.send(address: invoice.address, sats: amount, isMaxAmount: useMaxAmount) + if let contactPublicKey { + await PrivatePaykitService.shared.discardRemoteOnchainEndpoints( + publicKey: contactPublicKey, + addresses: [invoice.address] + ) + } // Create pre-activity metadata for tags and activity address await createPreActivityMetadata(paymentId: txid, address: invoice.address, txId: txid, feeRate: wallet.selectedFeeRateSatsPerVByte) diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift new file mode 100644 index 00000000..5bf8f12a --- /dev/null +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -0,0 +1,208 @@ +@testable import Bitkit +import Paykit +import XCTest + +final class PrivatePaykitServiceTests: XCTestCase { + func testRoleSelectionUsesLexicographicPubkyKeys() { + let lower = "pubky1111111111111111111111111111111111111111111111111111" + let higher = "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + + XCTAssertTrue(PrivatePaykitService.shouldInitiate(ownPublicKey: higher, remotePublicKey: lower)) + XCTAssertFalse(PrivatePaykitService.shouldInitiate(ownPublicKey: lower, remotePublicKey: higher)) + } + + func testPrivatePayloadLimitAcceptsV1EndpointMap() throws { + let invoicePayload = try PublicPaykitService.serializePayload(value: "lnbc1privateinvoice") + let addressPayload = try PublicPaykitService.serializePayload(value: "bcrt1qprivateaddress") + + XCTAssertTrue( + PrivatePaykitService.isNoisePayloadWithinLimit([ + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue: invoicePayload, + PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: addressPayload, + ]) + ) + } + + func testPrivatePayloadLimitRejectsOversizedEndpointMap() { + XCTAssertFalse( + PrivatePaykitService.isNoisePayloadWithinLimit([ + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue: String(repeating: "x", count: 1200), + ]) + ) + } + + func testPrivateRemovalTombstoneMapFitsNoisePayloadLimitAndIsNotPayable() { + let tombstonePayload = #"{"value":""}"# + let tombstoneMap = Dictionary(uniqueKeysWithValues: PublicPaykitService.MethodId.publishableMethodIds.map { + ($0.rawValue, tombstonePayload) + }) + + XCTAssertTrue(PrivatePaykitService.isNoisePayloadWithinLimit(tombstoneMap)) + XCTAssertNil( + PublicPaykitService.parseEndpoint( + methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + endpointData: tombstonePayload + ) + ) + } + + func testRecoveryMarkerPathIsStableAndDirectional() throws { + let alice = "pubky1111111111111111111111111111111111111111111111111111" + let bob = "pubkyzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + + let aliceToBob = try XCTUnwrap(PrivatePaykitService.recoveryMarkerPath(from: alice, to: bob)) + let aliceToBobAgain = try XCTUnwrap(PrivatePaykitService.recoveryMarkerPath(from: alice, to: bob)) + let bobToAlice = try XCTUnwrap(PrivatePaykitService.recoveryMarkerPath(from: bob, to: alice)) + + XCTAssertEqual(aliceToBob, aliceToBobAgain) + XCTAssertNotEqual(aliceToBob, bobToAlice) + XCTAssertTrue(aliceToBob.hasPrefix("/pub/paykit/v0/private-recovery/")) + XCTAssertTrue(aliceToBob.hasSuffix(".json")) + } + + func testStaleLinkFailureClassificationUsesTypedPaykitErrors() async { + let service = PrivatePaykitService() + let noiseFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "noise state decrypt failed")) + let linkHandleFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Validation(reason: "Unknown encrypted-link handle: 123")) + let networkFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Transport(reason: "connection timed out")) + let sessionFailure = await service.shouldCountAsStaleLinkFailure(PaykitFfiError.Session(reason: "No active session")) + + XCTAssertTrue(noiseFailure) + XCTAssertTrue(linkHandleFailure) + XCTAssertFalse(networkFailure) + XCTAssertFalse(sessionFailure) + } + + func testHandshakeTransportNotReadyIsPendingNotStaleState() async { + let service = PrivatePaykitService() + let error = PaykitFfiError + .Transport(reason: "failed to transition to transport mode: IsHandshake: pubky-noise transition_transport failed: IsHandshake") + let isPending = await service.isEncryptedHandshakePendingError(error) + let isStaleState = await service.isEncryptedHandshakeStateFailure(error) + + XCTAssertTrue(isPending) + XCTAssertFalse(isStaleState) + } + + func testDuplicatePaymentErrorClassificationUsesWrappedAppErrorReason() { + XCTAssertTrue( + PrivatePaykitService.isDuplicatePaymentError( + AppError(message: "Lightning payment failed", debugMessage: "Duplicate payment") + ) + ) + + XCTAssertFalse( + PrivatePaykitService.isDuplicatePaymentError( + AppError(message: "Lightning payment failed", debugMessage: "Route not found") + ) + ) + } + + func testWalletBackupDecodesExistingPayloadWithoutPrivatePaykitFields() throws { + let data = #"{"version":1,"createdAt":123,"transfers":[]}"#.data(using: .utf8)! + let payload = try JSONDecoder().decode(WalletBackupV1.self, from: data) + + XCTAssertTrue(payload.transfers.isEmpty) + XCTAssertNil(payload.privatePaykitHighestReservedReceiveIndexByAddressType) + XCTAssertNil(payload.privatePaykitContactLinks) + } + + func testWalletBackupRoundTripsPrivateReservationCeiling() throws { + let backup = WalletBackupV1( + version: 1, + createdAt: 123, + transfers: [], + privatePaykitHighestReservedReceiveIndexByAddressType: ["nativeSegwit": 5], + privatePaykitContactLinks: nil + ) + + let data = try JSONEncoder().encode(backup) + let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: data) + + XCTAssertEqual(decoded.version, backup.version) + XCTAssertEqual(decoded.createdAt, backup.createdAt) + XCTAssertTrue(decoded.transfers.isEmpty) + XCTAssertEqual(decoded.privatePaykitHighestReservedReceiveIndexByAddressType, backup.privatePaykitHighestReservedReceiveIndexByAddressType) + XCTAssertNil(decoded.privatePaykitContactLinks) + } + + func testReservationStoreBacksUpRestoredCeiling() async throws { + let suiteName = "PrivatePaykitServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = PrivatePaykitAddressReservationStore(defaults: defaults) + await store.restoreBackup(["nativeSegwit": 5]) + + let snapshot = await store.backupSnapshot() + XCTAssertEqual(snapshot?["nativeSegwit"], 5) + XCTAssertNil(snapshot?["taproot"]) + } + + func testWalletBackupRoundTripsPrivateContactLinks() throws { + let backup = WalletBackupV1( + version: 1, + createdAt: 123, + transfers: [], + privatePaykitHighestReservedReceiveIndexByAddressType: nil, + privatePaykitContactLinks: [ + "pubkycontact": PrivatePaykitContactLinkBackupV1( + publicKey: "pubkycontact", + linkSnapshotHex: "abcd", + handshakeSnapshotHex: nil, + remoteEndpoints: [ + PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue: #"{"value":"lnbc1cached"}"#, + PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue: #"{"value":"bcrt1qcached"}"#, + ], + linkCompletedAt: 456, + handshakeUpdatedAt: 123, + recoveryStartedAt: 789, + mainRecoveryAttemptId: "main-attempt", + responderRecoveryAttemptId: "responder-attempt" + ), + ] + ) + + let data = try JSONEncoder().encode(backup) + let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: data) + + XCTAssertEqual(decoded.version, backup.version) + XCTAssertEqual(decoded.createdAt, backup.createdAt) + XCTAssertTrue(decoded.transfers.isEmpty) + XCTAssertNil(decoded.privatePaykitHighestReservedReceiveIndexByAddressType) + XCTAssertEqual(decoded.privatePaykitContactLinks, backup.privatePaykitContactLinks) + } + + func testPrivatePaykitStateStoresOnlySnapshotsInKeychainState() throws { + let publicKey = "pubkycontact" + var contactState = PrivatePaykitService.ContactState() + contactState.linkSnapshotHex = "secret-link" + contactState.handshakeSnapshotHex = "secret-handshake" + contactState.remoteEndpoints = [ + PrivatePaykitService.StoredPaymentEntry( + methodId: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + endpointData: #"{"value":"lnbc1cached"}"# + ), + ] + contactState.localInvoice = PrivatePaykitService.StoredInvoice(bolt11: "lnbc1local", paymentHash: "hash", expiresAt: 123) + contactState.lastLocalPayloadHash = "payload-hash" + + let state = PrivatePaykitService.PrivatePaykitState(contacts: [publicKey: contactState]) + let secretData = try JSONEncoder().encode(state.secretState) + let cacheData = try JSONEncoder().encode(state.cacheState) + let secretJson = try XCTUnwrap(String(data: secretData, encoding: .utf8)) + let cacheJson = try XCTUnwrap(String(data: cacheData, encoding: .utf8)) + + XCTAssertTrue(secretJson.contains("secret-link")) + XCTAssertTrue(secretJson.contains("secret-handshake")) + XCTAssertFalse(secretJson.contains("lnbc1cached")) + XCTAssertFalse(secretJson.contains("lnbc1local")) + XCTAssertFalse(secretJson.contains("payload-hash")) + + XCTAssertTrue(cacheJson.contains("lnbc1cached")) + XCTAssertTrue(cacheJson.contains("lnbc1local")) + XCTAssertTrue(cacheJson.contains("payload-hash")) + XCTAssertFalse(cacheJson.contains("secret-link")) + XCTAssertFalse(cacheJson.contains("secret-handshake")) + } +} diff --git a/changelog.d/next/538.added.md b/changelog.d/next/538.added.md new file mode 100644 index 00000000..24b9dadd --- /dev/null +++ b/changelog.d/next/538.added.md @@ -0,0 +1 @@ +Contact payments now prefer private Paykit endpoints with dedicated receiving details for each contact when available.