From 5a9a9533fbd8c1d410284487ce9368a7e4fd8303 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 14 Jul 2026 12:19:22 +0200 Subject: [PATCH 01/16] feat: add signed paykit watch-only accounts --- Bitkit.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- Bitkit/MainNavView.swift | 1 + Bitkit/Models/BackupPayloads.swift | 1 + Bitkit/Models/PubkyAuthRequest.swift | 42 +- .../Localization/en.lproj/Localizable.strings | 24 ++ Bitkit/Services/BackupService.swift | 13 +- Bitkit/Services/LightningService.swift | 366 ++++++++++-------- Bitkit/Services/WatchOnlyAccountService.swift | 289 ++++++++++++++ Bitkit/Utilities/AppReset.swift | 1 + Bitkit/ViewModels/AppViewModel.swift | 4 +- Bitkit/ViewModels/NavigationViewModel.swift | 1 + Bitkit/ViewModels/WalletViewModel.swift | 28 +- .../Advanced/AdvancedSettingsView.swift | 9 + .../Advanced/WatchOnlyAccountsView.swift | 170 ++++++++ .../PubkyAuthApprovalSheet.swift | 68 ++++ BitkitTests/PrivatePaykitServiceTests.swift | 4 +- BitkitTests/PubkyAuthRequestTests.swift | 52 +++ .../WatchOnlyAccountServiceTests.swift | 156 ++++++++ Docs/watch-only-account-claim-v1.md | 48 +++ .../next/watch-only-account-claim.added.md | 1 + 21 files changed, 1122 insertions(+), 170 deletions(-) create mode 100644 Bitkit/Services/WatchOnlyAccountService.swift create mode 100644 Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift create mode 100644 BitkitTests/WatchOnlyAccountServiceTests.swift create mode 100644 Docs/watch-only-account-claim-v1.md create mode 100644 changelog.d/next/watch-only-account-claim.added.md diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index df84f0190..44c938996 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -173,6 +173,7 @@ Services/GeoService.swift, Services/LightningService.swift, Services/MigrationsService.swift, + Services/WatchOnlyAccountService.swift, Services/RNBackupClient.swift, Services/ServiceQueue.swift, Services/TransferStorage.swift, @@ -182,6 +183,7 @@ Utilities/Errors.swift, Utilities/Keychain.swift, Utilities/LightningAmountConversion.swift, + Utilities/LocalizeHelpers.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -201,12 +203,14 @@ Services/CoreService.swift, Services/GeoService.swift, Services/LightningService.swift, + Services/WatchOnlyAccountService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, Utilities/LightningAmountConversion.swift, + Utilities/LocalizeHelpers.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -570,8 +574,8 @@ inputFileListPaths = ( ); inputPaths = ( - "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/LDKNodeFFI.framework/_CodeSignature/CodeResources", - "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/vss_rust_client_ffiFFI.framework/_CodeSignature/CodeResources", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/LDKNodeFFI.framework", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/vss_rust_client_ffiFFI.framework", ); name = "Remove Static Framework Stubs"; outputFileListPaths = ( @@ -1193,7 +1197,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.39"; + version = "0.7.0-rc.54"; }; }; 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 36edf0db5..2bf66c102 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "c3593aebb7efe2605c08e40fee7a72d382d44401", - "version" : "0.7.0-rc.39" + "revision" : "1cd8f08fbe4c1814cfcba88f279c00bf2ebc71ac", + "version" : "0.7.0-rc.54" } }, { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index bee39ae5c..828a20c1b 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -567,6 +567,7 @@ struct MainNavView: View { case .electrumSettings: ElectrumSettingsScreen() case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() + case .watchOnlyAccounts: WatchOnlyAccountsView() case .devSettings: DevSettingsView() // Dev settings diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index cfe41e312..28debd942 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -9,6 +9,7 @@ struct WalletBackupV1: Codable { let transfers: [Transfer] let privatePaykitHighestReservedReceiveIndexByAddressType: [String: UInt32]? let paykitSdkBackupState: String? + let watchOnlyAccounts: [WatchOnlyAccountRecord]? } struct MetadataBackupV1: Codable { diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 3607770c8..80d52eb98 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -1,6 +1,20 @@ import Foundation import Paykit +enum PubkyAuthClaim: String, Equatable { + case watchOnlyAccountV1 = "watch-only-account-v1" + + static let queryParameter = "x-bitkit-claim" + static let watchOnlyAccountCapabilities = "/pub/paykit/v0/bitkit/server/:rw" +} + +enum PubkyAuthRequestError: Error, Equatable { + case invalidUrl + case duplicateBitkitClaim + case unsupportedBitkitClaim(String) + case invalidBitkitClaimCapabilities +} + // MARK: - PubkyAuth Permission struct PubkyAuthPermission { @@ -24,22 +38,48 @@ struct PubkyAuthRequest { let capabilities: String let permissions: [PubkyAuthPermission] let serviceNames: [String] + let bitkitClaim: PubkyAuthClaim? static func parse(url: String) throws -> PubkyAuthRequest { let details = try Paykit.parsePubkyAuthUrl(authUrl: url) let capabilities = details.capabilities ?? "" let permissions = parseCapabilities(capabilities) let serviceNames = permissions.compactMap { extractServiceName($0.path) } + let bitkitClaim = try parseBitkitClaim(url: url, capabilities: capabilities) return PubkyAuthRequest( rawUrl: url, kind: details.kind, relay: details.relayUrl ?? "", capabilities: capabilities, permissions: permissions, - serviceNames: serviceNames + serviceNames: serviceNames, + bitkitClaim: bitkitClaim ) } + static func parseBitkitClaim(url: String, capabilities: String) throws -> PubkyAuthClaim? { + guard let components = URLComponents(string: url) else { + throw PubkyAuthRequestError.invalidUrl + } + + let claimValues = components.queryItems? + .filter { $0.name == PubkyAuthClaim.queryParameter } + .map { $0.value ?? "" } ?? [] + + guard claimValues.count <= 1 else { + throw PubkyAuthRequestError.duplicateBitkitClaim + } + guard let claimValue = claimValues.first else { return nil } + guard let claim = PubkyAuthClaim(rawValue: claimValue) else { + throw PubkyAuthRequestError.unsupportedBitkitClaim(claimValue) + } + guard capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities else { + throw PubkyAuthRequestError.invalidBitkitClaimCapabilities + } + + return claim + } + static func parseCapabilities(_ caps: String) -> [PubkyAuthPermission] { caps .split(separator: ",") diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index cf01ee0b3..7ed5441e8 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -664,6 +664,16 @@ "pubky_auth__description_prefix" = "A service is requesting permission to access and edit your "; "pubky_auth__description_suffix" = " data."; "pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS"; +"pubky_auth__watch_only_account_title" = "WATCH-ONLY BITCOIN ACCOUNT"; +"pubky_auth__watch_only_account_description" = "Share a watch-only Bitcoin account. This lets the service generate receive addresses and view this account's transactions and balance, but not spend funds."; +"pubky_auth__watch_only_account_default_name" = "{service} account"; +"pubky_auth__watch_only_account_fallback_name" = "Paykit server account"; +"pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; +"pubky_auth__watch_only_auth_request_error" = "This authorization request is missing a valid secret."; +"pubky_auth__watch_only_account_name_label" = "ACCOUNT NAME"; +"pubky_auth__watch_only_account_name_placeholder" = "Name this account"; +"pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub."; +"pubky_auth__watch_only_transport_unavailable" = "This request needs the signed watch-only claim transport from the Paykit SDK before it can be authorized."; "pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky."; "pubky_auth__authorizing" = "Authorizing..."; "pubky_auth__success_title" = "Authorization Successful"; @@ -677,6 +687,20 @@ "pubky_auth__use_ring_desc" = "Your identity was created with Pubky Ring. Open Ring to approve this request."; "pubky_auth__invalid_request" = "Invalid auth request"; "pubky_auth__approval_failed" = "Authorization Failed"; +"watch_only_accounts__copy_xpub" = "Copy xpub"; +"watch_only_accounts__description" = "Each approved Paykit server gets a separate Bitcoin account. Turning tracking off rebuilds the wallet without that account until you turn it on again."; +"watch_only_accounts__empty_description" = "Accounts appear here after you approve a Paykit server setup request."; +"watch_only_accounts__empty_title" = "No server accounts"; +"watch_only_accounts__name" = "ACCOUNT NAME"; +"watch_only_accounts__name_placeholder" = "Account name"; +"watch_only_accounts__name_saved" = "Account name saved"; +"watch_only_accounts__pending_delivery" = "Setup pending: the signed xpub claim has not been delivered."; +"watch_only_accounts__save_name" = "Save name"; +"watch_only_accounts__title" = "Server Accounts"; +"watch_only_accounts__tracking" = "Track account"; +"watch_only_accounts__tracking_disabled" = "Account tracking disabled"; +"watch_only_accounts__tracking_enabled" = "Account tracking enabled"; +"watch_only_accounts__xpub" = "EXTENDED PUBLIC KEY"; "settings__settings" = "Settings"; "settings__dev_enabled_title" = "Dev Options Enabled"; "settings__dev_enabled_message" = "Developer options are now enabled throughout the app."; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 1d65757d5..d7f678659 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -207,6 +207,8 @@ class BackupService { let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) + try WatchOnlyAccountStore.restore(payload.watchOnlyAccounts) + await WatchOnlyAccountManager.shared.reload() pendingPaykitSdkBackupState = payload.paykitSdkBackupState didRestoreWalletBackup = true @@ -369,6 +371,14 @@ class BackupService { } .store(in: &cancellables) + WatchOnlyAccountStore.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) @@ -708,7 +718,8 @@ class BackupService { createdAt: UInt64(Date().timeIntervalSince1970 * 1000), transfers: transfers, privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType, - paykitSdkBackupState: paykitSdkBackupState + paykitSdkBackupState: paykitSdkBackupState, + watchOnlyAccounts: WatchOnlyAccountStore.load() ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 28ac102f2..666ae387c 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -169,6 +169,8 @@ class LightningService { } } + try await registerEnabledWatchOnlyAccounts(walletIndex: walletIndex) + Logger.info("LDK node setup") // Clear memory @@ -479,6 +481,55 @@ class LightningService { } } + func createAndTrackWatchOnlyAccount(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + let xpub = try node.exportOnchainWalletAccountXpub(addressType: addressType, accountIndex: accountIndex) + let isAlreadyTracked = node.listOnchainWalletAccounts().contains { + $0.addressType == addressType && $0.accountIndex == accountIndex + } + if !isAlreadyTracked { + try node.addOnchainWalletAccount(addressType: addressType, accountIndex: accountIndex, xpub: xpub) + } + try node.syncWallets() + return xpub + } + } + + private func registerEnabledWatchOnlyAccounts(walletIndex: Int) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + let records = WatchOnlyAccountStore.load().filter { + $0.walletIndex == walletIndex && $0.isTrackingEnabled + } + guard !records.isEmpty else { return } + + try await ServiceQueue.background(.ldk) { + let registered = Set(node.listOnchainWalletAccounts().map { + "\($0.addressType)-\($0.accountIndex)" + }) + + for record in records { + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + let key = "\(addressType)-\(record.accountIndex)" + if !registered.contains(key) { + try node.addOnchainWalletAccount( + addressType: addressType, + accountIndex: record.accountIndex, + xpub: record.xpub + ) + } + } + } + } + func newAddress() async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -1188,193 +1239,198 @@ extension LightningService { extension LightningService { func listenForEvents(onEvent: ((Event) -> Void)? = nil) { - Task { - while true { - guard let node = self.node else { - Logger.error("LDK node not started") - return - } + Task { [weak self] in + guard let self else { return } + await listenForEventsLoop(onEvent: onEvent) + } + } - let event = await node.nextEventAsync() + private func listenForEventsLoop(onEvent: ((Event) -> Void)?) async { + while true { + guard let node else { + Logger.error("LDK node not started") + return + } - do { - try node.eventHandled() - } catch { - Logger.error(error, context: "node.eventHandled()") - } + let event = await node.nextEventAsync() - if case .channelReady = event { - await refreshChannelCache() - } + do { + try node.eventHandled() + } catch { + Logger.error(error, context: "node.eventHandled()") + } - onEvent?(event) + if case .channelReady = event { + await refreshChannelCache() + } - switch event { - case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): - Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)") - Task { - let hash = paymentId ?? paymentHash - do { - try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) - } catch { - Logger.error("Failed to handle payment success for \(hash): \(error)", context: "LightningService") - } - } - case let .paymentFailed(paymentId, paymentHash, reason): - Logger.info( - "❌ Payment failed: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash ?? "") reason: \(reason.debugDescription)" - ) - Task { - if let hash = paymentId ?? paymentHash { - do { - try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) - } catch { - Logger.error("Failed to handle payment failure for \(hash): \(error)", context: "LightningService") - } - } else { - Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService") - } + onEvent?(event) + + switch event { + case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): + Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)") + Task { + let hash = paymentId ?? paymentHash + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment success for \(hash): \(error)", context: "LightningService") } - case let .paymentReceived(paymentId, paymentHash, amountMsat, _): - Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)") - Task { - let hash = paymentId ?? paymentHash + } + case let .paymentFailed(paymentId, paymentHash, reason): + Logger.info( + "❌ Payment failed: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash ?? "") reason: \(reason.debugDescription)" + ) + Task { + if let hash = paymentId ?? paymentHash { do { try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) } catch { - Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService") + Logger.error("Failed to handle payment failure for \(hash): \(error)", context: "LightningService") } + } else { + Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService") } - case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, _, _): - Logger.info( - "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" - ) - case let .probeSuccessful(paymentId, paymentHash): - Logger.info("🤑 Probe successful: paymentId: \(paymentId) paymentHash: \(paymentHash)") - case let .probeFailed(paymentId, paymentHash, shortChannelId): - Logger - .info( - "❌ Probe failed: paymentId: \(paymentId) paymentHash: \(paymentHash) shortChannelId: \(String(describing: shortChannelId))" - ) - // Payment claimable doesn't need activity update - it's still pending - // The payment will be updated when it succeeds or fails via paymentSuccessful/paymentFailed events - case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): - Logger.info( - "⏳ Channel pending: channelId: \(channelId) userChannelId: \(userChannelId) formerTemporaryChannelId: \(formerTemporaryChannelId) counterpartyNodeId: \(counterpartyNodeId) fundingTxo: \(fundingTxo)" - ) - await refreshChannelCache() - case let .channelReady(channelId, userChannelId, counterpartyNodeId, fundingTxo): - Logger.info( - "👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") fundingTxo: \(fundingTxo != nil ? "\(fundingTxo!.txid):\(fundingTxo!.vout)" : "nil")" - ) - case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason): - let reasonString = reason.map { String(describing: $0) } ?? "" - Logger.info( - "⛔ Channel closed: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") reason: \(reasonString)" + } + case let .paymentReceived(paymentId, paymentHash, amountMsat, _): + Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)") + Task { + let hash = paymentId ?? paymentHash + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService") + } + } + case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, _, _): + Logger.info( + "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" + ) + case let .probeSuccessful(paymentId, paymentHash, _): + Logger.info("🤑 Probe successful: paymentId: \(paymentId) paymentHash: \(paymentHash)") + case let .probeFailed(paymentId, paymentHash, shortChannelId, _): + Logger + .info( + "❌ Probe failed: paymentId: \(paymentId) paymentHash: \(paymentHash) shortChannelId: \(String(describing: shortChannelId))" ) + // Payment claimable doesn't need activity update - it's still pending + // The payment will be updated when it succeeds or fails via paymentSuccessful/paymentFailed events + case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): + Logger.info( + "⏳ Channel pending: channelId: \(channelId) userChannelId: \(userChannelId) formerTemporaryChannelId: \(formerTemporaryChannelId) counterpartyNodeId: \(counterpartyNodeId) fundingTxo: \(fundingTxo)" + ) + await refreshChannelCache() + case let .channelReady(channelId, userChannelId, counterpartyNodeId, fundingTxo): + Logger.info( + "👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") fundingTxo: \(fundingTxo != nil ? "\(fundingTxo!.txid):\(fundingTxo!.vout)" : "nil")" + ) + case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason): + let reasonString = reason.map { String(describing: $0) } ?? "" + Logger.info( + "⛔ Channel closed: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") reason: \(reasonString)" + ) - let channelIdString = channelId.description - let channel = await MainActor.run { - channelCache[channelIdString] - } + let channelIdString = channelId.description + let channel = await MainActor.run { + channelCache[channelIdString] + } - if let channel { - await registerClosedChannel(channel: channel, reason: reasonString) - _ = await MainActor.run { - channelCache.removeValue(forKey: channelIdString) - } - } else { - Logger.error( - "Could not find channel details for closed channel: channelId=\(channelIdString) userChannelId=\(userChannelId) in cache", - context: "LightningService" - ) + if let channel { + await registerClosedChannel(channel: channel, reason: reasonString) + _ = await MainActor.run { + channelCache.removeValue(forKey: channelIdString) } - case .paymentForwarded: - break + } else { + Logger.error( + "Could not find channel details for closed channel: channelId=\(channelIdString) userChannelId=\(userChannelId) in cache", + context: "LightningService" + ) + } + case .paymentForwarded: + break // MARK: New Onchain Transaction Events - case let .onchainTransactionReceived(txid, details): - Logger.info("📥 Onchain transaction received: txid=\(txid) amountSats=\(details.amountSats)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionReceived(txid: txid, details: details) - } catch { - Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService") - } + case let .onchainTransactionReceived(txid, details): + Logger.info("📥 Onchain transaction received: txid=\(txid) amountSats=\(details.amountSats)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReceived(txid: txid, details: details) + } catch { + Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService") } - case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): - Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionConfirmed( - txid: txid, - details: details - ) - } catch { - Logger.error("Failed to handle transaction confirmed for \(txid): \(error)", context: "LightningService") - } + } + case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): + Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionConfirmed( + txid: txid, + details: details + ) + } catch { + Logger.error("Failed to handle transaction confirmed for \(txid): \(error)", context: "LightningService") } - case let .onchainTransactionReplaced(txid, conflicts): - Logger.info("🔄 Onchain transaction replaced (RBF): txid=\(txid) by \(conflicts.count) conflict(s)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionReplaced(txid: txid, conflicts: conflicts) - } catch { - Logger.error("Failed to handle transaction replaced for \(txid): \(error)", context: "LightningService") - } + } + case let .onchainTransactionReplaced(txid, conflicts): + Logger.info("🔄 Onchain transaction replaced (RBF): txid=\(txid) by \(conflicts.count) conflict(s)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReplaced(txid: txid, conflicts: conflicts) + } catch { + Logger.error("Failed to handle transaction replaced for \(txid): \(error)", context: "LightningService") } - case let .onchainTransactionReorged(txid): - Logger.warn("⚠️ Onchain transaction reorged (unconfirmed): txid=\(txid)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionReorged(txid: txid) - } catch { - Logger.error("Failed to handle transaction reorged for \(txid): \(error)", context: "LightningService") - } + } + case let .onchainTransactionReorged(txid): + Logger.warn("⚠️ Onchain transaction reorged (unconfirmed): txid=\(txid)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReorged(txid: txid) + } catch { + Logger.error("Failed to handle transaction reorged for \(txid): \(error)", context: "LightningService") } - case let .onchainTransactionEvicted(txid): - Logger.warn("🗑️ Onchain transaction removed from mempool: txid=\(txid)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionEvicted(txid: txid) - } catch { - Logger.error("Failed to handle transaction evicted for \(txid): \(error)", context: "LightningService") - } + } + case let .onchainTransactionEvicted(txid): + Logger.warn("🗑️ Onchain transaction removed from mempool: txid=\(txid)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionEvicted(txid: txid) + } catch { + Logger.error("Failed to handle transaction evicted for \(txid): \(error)", context: "LightningService") } + } // MARK: Sync Events - case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): - Logger - .debug( - "🔄 Sync progress: type=\(syncType) progress=\(progressPercent)% current=\(currentBlockHeight) target=\(targetBlockHeight)" - ) - case let .syncCompleted(syncType, syncedBlockHeight): - Logger.info("✅ Sync completed: type=\(syncType) height=\(syncedBlockHeight)") - // Send sync status update - PassthroughSubject is thread-safe - syncStatusChangedSubject.send(UInt64(Date().timeIntervalSince1970)) + case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): + Logger + .debug( + "🔄 Sync progress: type=\(syncType) progress=\(progressPercent)% current=\(currentBlockHeight) target=\(targetBlockHeight)" + ) + case let .syncCompleted(syncType, syncedBlockHeight): + Logger.info("✅ Sync completed: type=\(syncType) height=\(syncedBlockHeight)") + // Send sync status update - PassthroughSubject is thread-safe + syncStatusChangedSubject.send(UInt64(Date().timeIntervalSince1970)) // MARK: Balance Events - case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, _, _, oldLightning, newLightning): - Logger - .info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)") + case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, _, _, oldLightning, newLightning): + Logger + .info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)") // MARK: Splice Events - case let .splicePending(channelId, userChannelId, counterpartyNodeId, newFundingTxo): - Logger - .info( - "🔀 Splice pending: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) newFundingTxo=\(newFundingTxo)" - ) - await refreshChannelCache() - case let .spliceFailed(channelId, userChannelId, counterpartyNodeId, abandonedFundingTxo): - Logger - .warn( - "❌ Splice failed: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) abandonedFundingTxo=\(abandonedFundingTxo != nil ? "\(abandonedFundingTxo!.txid):\(abandonedFundingTxo!.vout)" : "nil")" - ) - } + case let .splicePending(channelId, userChannelId, counterpartyNodeId, newFundingTxo): + Logger + .info( + "🔀 Splice pending: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) newFundingTxo=\(newFundingTxo)" + ) + await refreshChannelCache() + case let .spliceFailed(channelId, userChannelId, counterpartyNodeId, abandonedFundingTxo): + Logger + .warn( + "❌ Splice failed: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) abandonedFundingTxo=\(abandonedFundingTxo != nil ? "\(abandonedFundingTxo!.txid):\(abandonedFundingTxo!.vout)" : "nil")" + ) } } } diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift new file mode 100644 index 000000000..21904a0a4 --- /dev/null +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -0,0 +1,289 @@ +import Combine +import CryptoKit +import Foundation +import LDKNode + +enum WatchOnlyAccountSetupState: String, Codable { + case pendingDelivery + case active +} + +struct WatchOnlyAccountRecord: Codable, Equatable, Identifiable { + let id: UUID + let walletIndex: Int + let accountIndex: UInt32 + let addressType: String + let xpub: String + let requestFingerprint: String + let createdAt: UInt64 + var name: String + var isTrackingEnabled: Bool + var setupState: WatchOnlyAccountSetupState + + var derivationPath: String { + let coinType = Env.network == .bitcoin ? "0" : "1" + return "m/84'/\(coinType)'/\(accountIndex)'" + } +} + +enum WatchOnlyAccountError: LocalizedError, Equatable { + case invalidAccountName + case invalidAuthRequest + case invalidExtendedPublicKey + case companionTransportUnavailable + + var errorDescription: String? { + switch self { + case .invalidAccountName: + t("pubky_auth__watch_only_account_name_error") + case .invalidAuthRequest: + t("pubky_auth__watch_only_auth_request_error") + case .invalidExtendedPublicKey: + t("pubky_auth__watch_only_account_xpub_error") + case .companionTransportUnavailable: + t("pubky_auth__watch_only_transport_unavailable") + } + } +} + +protocol WatchOnlyAccountClaimTransport: Sendable { + func deliver(payload: Data, authUrl: String) async throws +} + +struct UnavailableWatchOnlyAccountClaimTransport: WatchOnlyAccountClaimTransport { + func deliver(payload _: Data, authUrl _: String) async throws { + throw WatchOnlyAccountError.companionTransportUnavailable + } +} + +protocol WatchOnlyAccountNodeHandling: AnyObject { + var currentWalletIndex: Int { get } + func createAndTrackWatchOnlyAccount(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String +} + +extension LightningService: WatchOnlyAccountNodeHandling {} + +enum WatchOnlyAccountStore { + static let walletBackupDataChangedPublisher = walletBackupDataChangedSubject.eraseToAnyPublisher() + + private static let defaultsKey = "watchOnlyAccountsV1" + private static let walletBackupDataChangedSubject = PassthroughSubject() + + static func load(defaults: UserDefaults = .standard) -> [WatchOnlyAccountRecord] { + guard let data = defaults.data(forKey: defaultsKey), + let records = try? JSONDecoder().decode([WatchOnlyAccountRecord].self, from: data) + else { return [] } + + return records.sorted { $0.accountIndex < $1.accountIndex } + } + + static func save(_ records: [WatchOnlyAccountRecord], defaults: UserDefaults = .standard) throws { + let data = try JSONEncoder().encode(records) + defaults.set(data, forKey: defaultsKey) + walletBackupDataChangedSubject.send() + } + + static func restore(_ records: [WatchOnlyAccountRecord]?, defaults: UserDefaults = .standard) throws { + try save(records ?? [], defaults: defaults) + } +} + +@Observable +@MainActor +final class WatchOnlyAccountManager { + static let shared = WatchOnlyAccountManager() + + private(set) var accounts: [WatchOnlyAccountRecord] + + private let defaults: UserDefaults + private let node: WatchOnlyAccountNodeHandling + private let transport: WatchOnlyAccountClaimTransport + + init( + defaults: UserDefaults = .standard, + node: WatchOnlyAccountNodeHandling = LightningService.shared, + transport: WatchOnlyAccountClaimTransport = UnavailableWatchOnlyAccountClaimTransport() + ) { + self.defaults = defaults + self.node = node + self.transport = transport + accounts = WatchOnlyAccountStore.load(defaults: defaults) + } + + func accounts(for walletIndex: Int) -> [WatchOnlyAccountRecord] { + accounts.filter { $0.walletIndex == walletIndex } + } + + func prepareSignedClaim(authUrl: String, name: String, secretKeyHex: String) async throws -> (WatchOnlyAccountRecord, Data) { + let normalizedName = try Self.normalizedName(name) + let fingerprint = Self.requestFingerprint(authUrl) + + if let existing = accounts.first(where: { + $0.walletIndex == node.currentWalletIndex && $0.requestFingerprint == fingerprint + }) { + if existing.name != normalizedName { + try rename(id: existing.id, name: normalizedName) + } + let refreshed = accounts.first(where: { $0.id == existing.id }) ?? existing + let payload = try WatchOnlyAccountClaimCodec.encode(record: refreshed, authUrl: authUrl, secretKeyHex: secretKeyHex) + return (refreshed, payload) + } + + let walletAccounts = accounts(for: node.currentWalletIndex) + let highestAccountIndex = walletAccounts.map(\.accountIndex).max() ?? 0 + guard highestAccountIndex < UInt32(Int32.max) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + let (accountIndex, overflow) = highestAccountIndex.addingReportingOverflow(1) + guard !overflow else { throw WatchOnlyAccountError.invalidExtendedPublicKey } + let addressType = LDKNode.AddressType.nativeSegwit + let xpub = try await node.createAndTrackWatchOnlyAccount(accountIndex: accountIndex, addressType: addressType) + let record = WatchOnlyAccountRecord( + id: UUID(), + walletIndex: node.currentWalletIndex, + accountIndex: accountIndex, + addressType: addressType.stringValue, + xpub: xpub, + requestFingerprint: fingerprint, + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), + name: normalizedName, + isTrackingEnabled: true, + setupState: .pendingDelivery + ) + + accounts.append(record) + try persist() + return try (record, WatchOnlyAccountClaimCodec.encode(record: record, authUrl: authUrl, secretKeyHex: secretKeyHex)) + } + + func deliver(payload: Data, authUrl: String) async throws { + try await transport.deliver(payload: payload, authUrl: authUrl) + } + + func markSetupActive(id: UUID) throws { + guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } + accounts[index].setupState = .active + try persist() + } + + func rename(id: UUID, name: String) throws { + let normalizedName = try Self.normalizedName(name) + guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } + accounts[index].name = normalizedName + try persist() + } + + func setTrackingEnabled(id: UUID, enabled: Bool) throws { + guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } + accounts[index].isTrackingEnabled = enabled + try persist() + } + + func reload() { + accounts = WatchOnlyAccountStore.load(defaults: defaults) + } + + private func persist() throws { + accounts.sort { $0.accountIndex < $1.accountIndex } + try WatchOnlyAccountStore.save(accounts, defaults: defaults) + } + + private static func normalizedName(_ name: String) throws -> String { + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, normalized.count <= 64 else { + throw WatchOnlyAccountError.invalidAccountName + } + return normalized + } + + private static func requestFingerprint(_ authUrl: String) -> String { + Data(SHA256.hash(data: Data(authUrl.utf8))).base64EncodedString() + } +} + +enum WatchOnlyAccountClaimCodec { + static let version: UInt8 = 1 + static let nativeSegwitAddressType: UInt8 = 0 + static let serializedXpubLength = 78 + static let payloadLength = 1 + 4 + 1 + serializedXpubLength + 64 + + private static let signatureDomain = Data("x-bitkit-claim|watch-only-account-v1|".utf8) + + static func encode(record: WatchOnlyAccountRecord, authUrl: String, secretKeyHex: String) throws -> Data { + guard record.addressType == LDKNode.AddressType.nativeSegwit.stringValue else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + let rawXpub = try serializedXpub(record.xpub) + var claim = Data([version]) + claim.append(contentsOf: withUnsafeBytes(of: record.accountIndex.bigEndian, Array.init)) + claim.append(nativeSegwitAddressType) + claim.append(rawXpub) + + let privateKeyBytes = secretKeyHex.trimmingCharacters(in: .whitespacesAndNewlines).hexaData + let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyBytes) + let requestSecretHash = try requestSecretHash(authUrl: authUrl) + let signature = try privateKey.signature(for: signatureDomain + requestSecretHash + claim) + claim.append(signature) + return claim + } + + static func requestSecretHash(authUrl: String) throws -> Data { + guard let components = URLComponents(string: authUrl) else { + throw WatchOnlyAccountError.invalidAuthRequest + } + let secrets = components.queryItems?.filter { $0.name == "secret" }.compactMap(\.value) ?? [] + guard secrets.count == 1, let secret = secrets.first, !secret.isEmpty else { + throw WatchOnlyAccountError.invalidAuthRequest + } + return Data(SHA256.hash(data: Data(secret.utf8))) + } + + static func serializedXpub(_ xpub: String) throws -> Data { + let decoded = try Base58Check.decode(xpub) + guard decoded.count == serializedXpubLength else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + return decoded + } +} + +private enum Base58Check { + private static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + private static let alphabetIndexes = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, $0) }) + + static func decode(_ value: String) throws -> Data { + guard !value.isEmpty else { throw WatchOnlyAccountError.invalidExtendedPublicKey } + + var bytes = [UInt8](repeating: 0, count: 1) + for character in value { + guard let digit = alphabetIndexes[character] else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + var carry = digit + for index in bytes.indices.reversed() { + carry += Int(bytes[index]) * 58 + bytes[index] = UInt8(carry & 0xFF) + carry >>= 8 + } + while carry > 0 { + bytes.insert(UInt8(carry & 0xFF), at: 0) + carry >>= 8 + } + } + + let leadingZeros = value.prefix { $0 == "1" }.count + bytes = Array(repeating: 0, count: leadingZeros) + Array(bytes.drop { $0 == 0 }) + guard bytes.count > 4 else { throw WatchOnlyAccountError.invalidExtendedPublicKey } + + let payload = Data(bytes.dropLast(4)) + let checksum = Data(bytes.suffix(4)) + let firstHash = SHA256.hash(data: payload) + let expectedChecksum = Data(SHA256.hash(data: Data(firstHash))).prefix(4) + guard checksum == expectedChecksum else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + return payload + } +} diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index ddc12ddfe..fda98b449 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -46,6 +46,7 @@ enum AppReset { if let bundleID = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleID) } + WatchOnlyAccountManager.shared.reload() // Singleton retains stale @AppStorage values after removePersistentDomain SettingsViewModel.shared.resetToDefaults() diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 473bb95c0..f2fdb7b5f 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -954,9 +954,9 @@ extension AppViewModel { break case .paymentForwarded: break - case .probeSuccessful(paymentId: _, paymentHash: _): + case .probeSuccessful: break - case .probeFailed(paymentId: _, paymentHash: _, shortChannelId: _): + case .probeFailed: break // MARK: New Onchain Transaction Events diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 374b4d817..f63ce95a6 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -97,6 +97,7 @@ enum Route: Hashable { case electrumSettings case rgsSettings case addressViewer + case watchOnlyAccounts case devSettings // Dev settings diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 9e64deb1b..f9b48a6d7 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -182,14 +182,14 @@ class WalletViewModel: ObservableObject { // Handle specific events for targeted UI updates switch event { - case let .probeSuccessful(paymentId, paymentHash: paymentHash): + case let .probeSuccessful(paymentId, paymentHash, _): self.cacheProbeOutcome( success: true, paymentId: paymentId, paymentHash: paymentHash, shortChannelId: nil ) - case let .probeFailed(paymentId, paymentHash: paymentHash, shortChannelId: shortChannelId): + case let .probeFailed(paymentId, paymentHash, shortChannelId, _): self.cacheProbeOutcome( success: false, paymentId: paymentId, @@ -417,6 +417,24 @@ class WalletViewModel: ObservableObject { syncState() } + func reloadWatchOnlyAccountTracking() async throws { + nodeLifecycleState = .starting + let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl + let rgsServerUrl = rgsConfigService.getCurrentServerUrl() + + do { + try await lightningService.restart( + electrumServerUrl: electrumServerUrl, + rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl + ) + nodeLifecycleState = .running + await refreshAndSyncState() + } catch { + nodeLifecycleState = .errorStarting(cause: error) + throw error + } + } + func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { let finalExpirySecs = expirySecs ?? 60 * 60 * 24 let invoice = try await lightningService.receive(amountSats: amountSats, description: note, expirySecs: finalExpirySecs) @@ -698,13 +716,13 @@ class WalletViewModel: ObservableObject { var pendingPaymentIds = paymentIds var lastFailure: ProbeOutcome? - return await withCheckedContinuation { continuation in + return await withCheckedContinuation { (continuation: CheckedContinuation) in var resumed = false addOnEvent(id: eventId) { event in guard !resumed else { return } switch event { - case let .probeSuccessful(paymentId, paymentHash: paymentHash): + case let .probeSuccessful(paymentId, paymentHash, _): guard pendingPaymentIds.contains(paymentId) else { return } resumed = true self.removeOnEvent(id: eventId) @@ -714,7 +732,7 @@ class WalletViewModel: ObservableObject { paymentHash: paymentHash, shortChannelId: nil )) - case let .probeFailed(paymentId, paymentHash: paymentHash, shortChannelId: shortChannelId): + case let .probeFailed(paymentId, paymentHash, shortChannelId, _): guard pendingPaymentIds.remove(paymentId) != nil else { return } lastFailure = .init( success: false, diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 09f5ef3f8..2b958dd83 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -63,6 +63,15 @@ struct AdvancedSettingsView: View { } .accessibilityIdentifier("AddressViewer") + NavigationLink(value: Route.watchOnlyAccounts) { + SettingsRow( + title: t("watch_only_accounts__title"), + iconName: "keyring", + rightText: String(WatchOnlyAccountManager.shared.accounts(for: LightningService.shared.currentWalletIndex).count) + ) + } + .accessibilityIdentifier("WatchOnlyAccounts") + // Networks section SettingsSectionHeader(t("settings__adv__section_networks")) .padding(.top, 16) diff --git a/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift new file mode 100644 index 000000000..4c6020262 --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift @@ -0,0 +1,170 @@ +import SwiftUI + +struct WatchOnlyAccountsView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @State private var manager = WatchOnlyAccountManager.shared + @State private var nameDrafts: [UUID: String] = [:] + @State private var updatingAccountId: UUID? + + private var visibleAccounts: [WatchOnlyAccountRecord] { + manager.accounts(for: LightningService.shared.currentWalletIndex) + } + + var body: some View { + VStack(spacing: 0) { + NavigationBar(title: t("watch_only_accounts__title")) + .padding(.horizontal, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + BodyMText(t("watch_only_accounts__description"), textColor: .white64) + .lineSpacing(4) + + if visibleAccounts.isEmpty { + emptyState + } else { + ForEach(visibleAccounts) { account in + accountCard(account) + } + } + } + .padding(16) + .bottomSafeAreaPadding() + } + } + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + manager.reload() + nameDrafts = Dictionary(uniqueKeysWithValues: visibleAccounts.map { ($0.id, $0.name) }) + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 8) { + TitleText(t("watch_only_accounts__empty_title")) + BodySText(t("watch_only_accounts__empty_description"), textColor: .white64) + .lineSpacing(4) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityIdentifier("WatchOnlyAccountsEmpty") + } + + private func accountCard(_ account: WatchOnlyAccountRecord) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + BodyMSBText(account.name) + CaptionText(account.derivationPath, textColor: .white64) + } + + Spacer() + + Toggle( + "", + isOn: Binding( + get: { account.isTrackingEnabled }, + set: { enabled in + Task { await updateTracking(account: account, enabled: enabled) } + } + ) + ) + .labelsHidden() + .toggleStyle(SwitchToggleStyle(tint: .brandAccent)) + .disabled(updatingAccountId != nil) + .accessibilityLabel(t("watch_only_accounts__tracking")) + .accessibilityIdentifier("WatchOnlyAccountTracking_\(account.accountIndex)") + } + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("watch_only_accounts__name"), textColor: .white64) + TextField( + t("watch_only_accounts__name_placeholder"), + text: Binding( + get: { nameDrafts[account.id] ?? account.name }, + set: { nameDrafts[account.id] = $0 } + ), + testIdentifier: "WatchOnlyAccountName_\(account.accountIndex)", + submitLabel: .done + ) + .onSubmit { saveName(account) } + } + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("watch_only_accounts__xpub"), textColor: .white64) + BodySText(account.xpub, textColor: .white64) + .lineLimit(2) + .truncationMode(.middle) + .accessibilityIdentifier("WatchOnlyAccountXpub_\(account.accountIndex)") + } + + HStack(spacing: 12) { + CustomButton(title: t("watch_only_accounts__save_name"), variant: .secondary) { + saveName(account) + } + .accessibilityIdentifier("WatchOnlyAccountSaveName_\(account.accountIndex)") + + CustomButton(title: t("watch_only_accounts__copy_xpub"), variant: .secondary) { + UIPasteboard.general.string = account.xpub + app.toast(type: .success, title: t("common__copied")) + } + .accessibilityIdentifier("WatchOnlyAccountCopyXpub_\(account.accountIndex)") + } + + if account.setupState == .pendingDelivery { + CaptionText(t("watch_only_accounts__pending_delivery"), textColor: .yellow) + .accessibilityIdentifier("WatchOnlyAccountPending_\(account.accountIndex)") + } + } + .padding(20) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("WatchOnlyAccount_\(account.accountIndex)") + } + + @MainActor + private func saveName(_ account: WatchOnlyAccountRecord) { + do { + try manager.rename(id: account.id, name: nameDrafts[account.id] ?? account.name) + app.toast(type: .success, title: t("watch_only_accounts__name_saved")) + } catch { + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + } + } + + @MainActor + private func updateTracking(account: WatchOnlyAccountRecord, enabled: Bool) async { + let previousValue = account.isTrackingEnabled + updatingAccountId = account.id + + do { + try manager.setTrackingEnabled(id: account.id, enabled: enabled) + try await wallet.reloadWatchOnlyAccountTracking() + app.toast( + type: .success, + title: enabled ? t("watch_only_accounts__tracking_enabled") : t("watch_only_accounts__tracking_disabled") + ) + } catch { + try? manager.setTrackingEnabled(id: account.id, enabled: previousValue) + try? await wallet.reloadWatchOnlyAccountTracking() + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + } + + updatingAccountId = nil + } +} + +#Preview { + NavigationStack { + WatchOnlyAccountsView() + .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 4ef7939a7..a81b8edd9 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -48,6 +48,7 @@ struct PubkyAuthApprovalSheet: View { @State private var state: ApprovalState = .authorize @State private var isShowingAuthCheck = false + @State private var watchOnlyAccountName = "" private enum ApprovalState { case authorize @@ -88,6 +89,12 @@ struct PubkyAuthApprovalSheet: View { } ) } + .task { + guard config.request.bitkitClaim != nil, watchOnlyAccountName.isEmpty else { return } + watchOnlyAccountName = config.request.serviceNames.first.map { + t("pubky_auth__watch_only_account_default_name", variables: ["service": $0]) + } ?? t("pubky_auth__watch_only_account_fallback_name") + } } // MARK: - Authorize State (Screen 3) @@ -100,6 +107,14 @@ struct PubkyAuthApprovalSheet: View { permissionsSection .padding(.bottom, 16) + if config.request.bitkitClaim != nil { + bitkitClaimSection + .padding(.bottom, 16) + + watchOnlyAccountNameSection + .padding(.bottom, 16) + } + Spacer() trustWarning @@ -132,6 +147,15 @@ struct PubkyAuthApprovalSheet: View { permissionsSection .padding(.bottom, 16) + if config.request.bitkitClaim != nil { + bitkitClaimSection + .padding(.bottom, 16) + + watchOnlyAccountNameSection + .disabled(true) + .padding(.bottom, 16) + } + Spacer() trustWarning @@ -224,6 +248,35 @@ struct PubkyAuthApprovalSheet: View { } } + @ViewBuilder + private var bitkitClaimSection: some View { + switch config.request.bitkitClaim { + case .some(.watchOnlyAccountV1): + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__watch_only_account_title"), textColor: .white64) + BodySText(t("pubky_auth__watch_only_account_description")) + .lineSpacing(4) + } + .padding(16) + .background(Color.gray6) + .cornerRadius(16) + .accessibilityIdentifier("PubkyAuthWatchOnlyAccountClaim") + case nil: + EmptyView() + } + } + + private var watchOnlyAccountNameSection: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("pubky_auth__watch_only_account_name_label"), textColor: .white64) + TextField( + t("pubky_auth__watch_only_account_name_placeholder"), + text: $watchOnlyAccountName, + testIdentifier: "PubkyAuthWatchOnlyAccountName" + ) + } + } + private var trustWarning: some View { BodySText(t("pubky_auth__trust_warning")) .lineSpacing(4) @@ -307,12 +360,27 @@ struct PubkyAuthApprovalSheet: View { return } + var preparedAccountId: UUID? + if config.request.bitkitClaim == .watchOnlyAccountV1 { + let preparedClaim = try await WatchOnlyAccountManager.shared.prepareSignedClaim( + authUrl: config.authUrl, + name: watchOnlyAccountName, + secretKeyHex: secretKey + ) + try await WatchOnlyAccountManager.shared.deliver(payload: preparedClaim.1, authUrl: config.authUrl) + preparedAccountId = preparedClaim.0.id + } + try await PubkyService.approveAuth( authUrl: config.authUrl, expectedCapabilities: config.request.capabilities, secretKeyHex: secretKey ) + if let preparedAccountId { + try WatchOnlyAccountManager.shared.markSetupActive(id: preparedAccountId) + } + state = .success } catch { Logger.error("Failed to approve pubky auth: \(error)", context: "PubkyAuthApprovalSheet") diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c4cc7bdf4..3272af742 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -120,6 +120,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(payload.transfers.isEmpty) XCTAssertNil(payload.privatePaykitHighestReservedReceiveIndexByAddressType) XCTAssertNil(payload.paykitSdkBackupState) + XCTAssertNil(payload.watchOnlyAccounts) } func testWalletBackupRoundTripsPrivateReservationCeilingAndSdkState() throws { @@ -128,7 +129,8 @@ final class PrivatePaykitServiceTests: XCTestCase { createdAt: 123, transfers: [], privatePaykitHighestReservedReceiveIndexByAddressType: ["nativeSegwit": 5], - paykitSdkBackupState: "AQID" + paykitSdkBackupState: "AQID", + watchOnlyAccounts: nil ) let data = try JSONEncoder().encode(backup) diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 3ce3dcbde..87a378c4a 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -3,6 +3,9 @@ import XCTest /// Tests for PubkyAuthRequest capability parsing and permission display. final class PubkyAuthRequestTests: XCTestCase { + private let relay = "https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F" + private let secret = "e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + func testParseUrlPreservesRequestedCapabilities() throws { let capabilities = "/pub/bitkit.to/:rw" let url = "pubkyauth://signin?caps=\(capabilities)&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" @@ -14,6 +17,48 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(request.permissions[0].path, "/pub/bitkit.to/") } + func testParseUrlRecognizesWatchOnlyAccountClaim() throws { + let capabilities = PubkyAuthClaim.watchOnlyAccountCapabilities + let url = authUrl(capabilities: capabilities, claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) + + let request = try PubkyAuthRequest.parse(url: url) + + XCTAssertEqual(request.bitkitClaim, .watchOnlyAccountV1) + } + + func testParseUrlWithoutBitkitClaimPreservesNormalAuth() throws { + let request = try PubkyAuthRequest.parse(url: authUrl(capabilities: "/pub/bitkit.to/:rw")) + + XCTAssertNil(request.bitkitClaim) + } + + func testParseUrlRejectsDuplicateBitkitClaim() { + let url = authUrl( + capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, + claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue, PubkyAuthClaim.watchOnlyAccountV1.rawValue] + ) + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .duplicateBitkitClaim) + } + } + + func testParseUrlRejectsUnknownBitkitClaim() { + let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, claimValues: ["unknown-v1"]) + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .unsupportedBitkitClaim("unknown-v1")) + } + } + + func testParseUrlRejectsWatchOnlyClaimWithOtherCapabilities() { + let url = authUrl(capabilities: "/pub/paykit/v0/:rw", claimValues: [PubkyAuthClaim.watchOnlyAccountV1.rawValue]) + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .invalidBitkitClaimCapabilities) + } + } + // MARK: - parseCapabilities func testParseCapabilitiesSingleEntry() { @@ -133,4 +178,11 @@ final class PubkyAuthRequestTests: XCTestCase { let permission = PubkyAuthPermission(path: "/test", accessLevel: "") XCTAssertEqual(permission.displayAccess, "") } + + private func authUrl(capabilities: String, claimValues: [String] = []) -> String { + let claims = claimValues + .map { "&\(PubkyAuthClaim.queryParameter)=\($0)" } + .joined() + return "pubkyauth://signin?caps=\(capabilities)&relay=\(relay)&secret=\(secret)\(claims)" + } } diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift new file mode 100644 index 000000000..990b89174 --- /dev/null +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -0,0 +1,156 @@ +@testable import Bitkit +import CryptoKit +import LDKNode +import XCTest + +final class WatchOnlyAccountServiceTests: XCTestCase { + func testSignedClaimContainsAccountMetadataAndVerifiableSignature() throws { + let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map(UInt8.init)) + let privateKeyBytes = Data(repeating: 7, count: 32) + let record = makeRecord(accountIndex: 42, xpub: base58CheckEncode(rawXpub)) + + let authUrl = "pubkyauth:///?secret=request-secret" + let payload = try WatchOnlyAccountClaimCodec.encode(record: record, authUrl: authUrl, secretKeyHex: privateKeyBytes.hex) + + XCTAssertEqual(payload.count, WatchOnlyAccountClaimCodec.payloadLength) + XCTAssertEqual(payload[0], WatchOnlyAccountClaimCodec.version) + XCTAssertEqual(payload[5], WatchOnlyAccountClaimCodec.nativeSegwitAddressType) + XCTAssertEqual(payload.subdata(in: 6 ..< 84), rawXpub) + + let accountIndex = payload[1 ..< 5].reduce(UInt32.zero) { ($0 << 8) | UInt32($1) } + XCTAssertEqual(accountIndex, 42) + + let unsignedClaim = payload.prefix(84) + let signature = payload.suffix(64) + let requestSecretHash = try WatchOnlyAccountClaimCodec.requestSecretHash(authUrl: authUrl) + let signable = Data("x-bitkit-claim|watch-only-account-v1|".utf8) + requestSecretHash + unsignedClaim + let publicKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyBytes).publicKey + XCTAssertTrue(publicKey.isValidSignature(signature, for: signable)) + } + + func testRequestSecretHashPercentDecodesWithoutTreatingPlusAsSpace() throws { + let hash = try WatchOnlyAccountClaimCodec.requestSecretHash( + authUrl: "pubkyauth://signin?secret=one%2Ftwo+three" + ) + + XCTAssertEqual(hash, Data(SHA256.hash(data: Data("one/two+three".utf8)))) + } + + @MainActor + func testEachRequestGetsANewAccountAndRetryReusesPendingAccount() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + let secret = Data(repeating: 9, count: 32).hex + + let first = try await manager.prepareSignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Store one", secretKeyHex: secret) + let retry = try await manager.prepareSignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Changed", secretKeyHex: secret) + let second = try await manager.prepareSignedClaim(authUrl: "pubkyauth:///?secret=two", name: "Store two", secretKeyHex: secret) + + XCTAssertEqual(first.0.accountIndex, 1) + XCTAssertEqual(retry.0.id, first.0.id) + XCTAssertEqual(second.0.accountIndex, 2) + XCTAssertEqual(node.createdAccountIndexes, [1, 2]) + XCTAssertEqual(WatchOnlyAccountStore.load(defaults: defaults).count, 2) + } + + @MainActor + func testRenameTrackingAndActiveStatePersist() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = WatchOnlyAccountManager(defaults: defaults, node: FakeWatchOnlyAccountNode()) + let prepared = try await manager.prepareSignedClaim( + authUrl: "pubkyauth:///?secret=state", + name: "Original", + secretKeyHex: Data(repeating: 3, count: 32).hex + ) + + try manager.rename(id: prepared.0.id, name: "Creator shop") + try manager.setTrackingEnabled(id: prepared.0.id, enabled: false) + try manager.markSetupActive(id: prepared.0.id) + + let stored = try XCTUnwrap(WatchOnlyAccountStore.load(defaults: defaults).first) + XCTAssertEqual(stored.name, "Creator shop") + XCTAssertFalse(stored.isTrackingEnabled) + XCTAssertEqual(stored.setupState, .active) + } + + private func makeRecord(accountIndex: UInt32, xpub: String) -> WatchOnlyAccountRecord { + WatchOnlyAccountRecord( + id: UUID(), + walletIndex: 0, + accountIndex: accountIndex, + addressType: LDKNode.AddressType.nativeSegwit.stringValue, + xpub: xpub, + requestFingerprint: "request", + createdAt: 1000, + name: "Test", + isTrackingEnabled: true, + setupState: .pendingDelivery + ) + } + + private func base58CheckEncode(_ payload: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + let firstHash = SHA256.hash(data: payload) + let checksum = Data(SHA256.hash(data: Data(firstHash))).prefix(4) + let source = [UInt8](payload + checksum) + var digits = [Int](repeating: 0, count: 1) + + for byte in source { + var carry = Int(byte) + for index in digits.indices.reversed() { + carry += digits[index] << 8 + digits[index] = carry % 58 + carry /= 58 + } + while carry > 0 { + digits.insert(carry % 58, at: 0) + carry /= 58 + } + } + + let leadingZeros = source.prefix { $0 == 0 }.count + return String(repeating: "1", count: leadingZeros) + String(digits.drop { $0 == 0 }.map { alphabet[$0] }) + } +} + +private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { + var currentWalletIndex = 0 + private(set) var createdAccountIndexes: [UInt32] = [] + + func createAndTrackWatchOnlyAccount(accountIndex: UInt32, addressType _: LDKNode.AddressType) async throws -> String { + createdAccountIndexes.append(accountIndex) + let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8(($0 + Int(accountIndex)) % 256) }) + return base58CheckEncode(rawXpub) + } + + private func base58CheckEncode(_ payload: Data) -> String { + let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + let firstHash = SHA256.hash(data: payload) + let checksum = Data(SHA256.hash(data: Data(firstHash))).prefix(4) + let source = [UInt8](payload + checksum) + var digits = [Int](repeating: 0, count: 1) + + for byte in source { + var carry = Int(byte) + for index in digits.indices.reversed() { + carry += digits[index] << 8 + digits[index] = carry % 58 + carry /= 58 + } + while carry > 0 { + digits.insert(carry % 58, at: 0) + carry /= 58 + } + } + + let leadingZeros = source.prefix { $0 == 0 }.count + return String(repeating: "1", count: leadingZeros) + String(digits.drop { $0 == 0 }.map { alphabet[$0] }) + } +} diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md new file mode 100644 index 000000000..7eb4f0f2f --- /dev/null +++ b/Docs/watch-only-account-claim-v1.md @@ -0,0 +1,48 @@ +# Bitkit watch-only account claim v1 + +This document records the client contract implemented by Bitkit iOS and Android for Paykit Server setup requests. + +## Request + +- The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. +- The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. +- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Retrying the same auth URL reuses its pending account. +- The user assigns a local name to the account. The name is not disclosed in the claim. + +## Signed claim + +The decrypted claim is 148 bytes: + +| Offset | Size | Value | +| --- | ---: | --- | +| 0 | 1 | Claim version, `0x01` | +| 1 | 4 | BIP account index, unsigned big-endian | +| 5 | 1 | Address type, `0x00` for native SegWit | +| 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | +| 84 | 64 | Ed25519 signature | + +The signature input is the byte concatenation: + +```text +UTF8("x-bitkit-claim|watch-only-account-v1|") +|| SHA256(UTF8(decoded_auth_request_secret)) +|| claim_bytes[0..<84] +``` + +The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. + +## Delivery and lifecycle + +- The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. +- The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. +- Bitkit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using the existing XSalsa20-Poly1305 format. +- Bitkit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Bitkit persists the account before delivery and retries the same pending claim idempotently. +- Disabling tracking rebuilds LDK Node without registering that account. It does not delete the xpub or revoke the server session. +- Account metadata is included in the existing encrypted wallet backup and uses the same JSON field names on iOS and Android. + +## Required shared protocol work + +The current Paykit app bindings do not expose the companion encrypted-relay operation, so both clients deliberately fail closed at delivery. Paykit must provide one shared binding that derives the domain-separated channel, encrypts, and posts the signed claim; duplicating Pubky relay cryptography in each app is not accepted. + +The server protocol must also communicate its highest issued external address index. Bitkit can then call LDK Node's account-specific reveal API before syncing. Without that high-water mark, addresses beyond the wallet lookahead cannot be guaranteed to appear, even though initial addresses work normally. diff --git a/changelog.d/next/watch-only-account-claim.added.md b/changelog.d/next/watch-only-account-claim.added.md new file mode 100644 index 000000000..960bfa65c --- /dev/null +++ b/changelog.d/next/watch-only-account-claim.added.md @@ -0,0 +1 @@ +Bitkit now creates, names, signs, backs up, and manages separate watch-only Bitcoin accounts for approved Paykit server setup requests. From 8b869478101044b79bd8337dd8010a83d0cb8f49 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 14:15:39 +0200 Subject: [PATCH 02/16] fix: harden paykit account lifecycle --- Bitkit.xcodeproj/project.pbxproj | 29 +- .../xcshareddata/swiftpm/Package.resolved | 28 +- Bitkit/Components/SettingsRow.swift | 19 +- Bitkit/Models/BackupPayloads.swift | 1 + Bitkit/Models/PubkyAuthRequest.swift | 8 +- .../Localization/en.lproj/Localizable.strings | 11 +- Bitkit/Services/BackupService.swift | 12 +- Bitkit/Services/LightningService.swift | 136 ++++-- Bitkit/Services/PubkyService.swift | 37 ++ Bitkit/Services/WatchOnlyAccountService.swift | 430 +++++++++++++----- Bitkit/Utilities/AppReset.swift | 2 +- Bitkit/Utilities/Errors.swift | 3 + Bitkit/ViewModels/WalletViewModel.swift | 18 - .../Advanced/WatchOnlyAccountsView.swift | 233 ++++++---- .../PubkyAuthApprovalSheet.swift | 161 ++++--- BitkitTests/PrivatePaykitServiceTests.swift | 4 +- BitkitTests/PubkyAuthApprovalSheetTests.swift | 290 ++++++++++++ BitkitTests/PubkyAuthRequestTests.swift | 8 + .../WatchOnlyAccountServiceTests.swift | 412 +++++++++++++---- Docs/watch-only-account-claim-v1.md | 29 +- .../next/watch-only-account-claim.added.md | 2 +- 21 files changed, 1437 insertions(+), 436 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 44c938996..8a8454646 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 182817C12F59A7F10055A441 /* Paykit in Frameworks */ = {isa = PBXBuildFile; productRef = 182817C02F59A7F10055A441 /* Paykit */; }; + 18B58A012F7A000100B58A01 /* Base58Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 18B58A032F7A000100B58A01 /* Base58Swift */; }; 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; @@ -203,14 +204,12 @@ Services/CoreService.swift, Services/GeoService.swift, Services/LightningService.swift, - Services/WatchOnlyAccountService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, Utilities/LightningAmountConversion.swift, - Utilities/LocalizeHelpers.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -260,6 +259,7 @@ buildActionMask = 2147483647; files = ( 182817C12F59A7F10055A441 /* Paykit in Frameworks */, + 18B58A012F7A000100B58A01 /* Base58Swift in Frameworks */, 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, @@ -401,6 +401,7 @@ 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, 182817C02F59A7F10055A441 /* Paykit */, + 18B58A032F7A000100B58A01 /* Base58Swift */, ); productName = Bitkit; productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */; @@ -511,6 +512,7 @@ 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */, + 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -574,8 +576,8 @@ inputFileListPaths = ( ); inputPaths = ( - "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/LDKNodeFFI.framework", - "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/vss_rust_client_ffiFFI.framework", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/LDKNodeFFI.framework/_CodeSignature", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/vss_rust_client_ffiFFI.framework/_CodeSignature", ); name = "Remove Static Framework Stubs"; outputFileListPaths = ( @@ -769,6 +771,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) BITKIT_NOTIFICATION_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -801,6 +804,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) BITKIT_NOTIFICATION_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -1173,7 +1177,15 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc33"; + version = "0.1.0-rc35"; + }; + }; + 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/keefertaylor/Base58Swift"; + requirement = { + kind = exactVersion; + version = 2.1.14; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { @@ -1197,7 +1209,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.54"; + version = "0.7.0-rc.55"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { @@ -1232,6 +1244,11 @@ package = 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */; productName = Paykit; }; + 18B58A032F7A000100B58A01 /* Base58Swift */ = { + isa = XCSwiftPackageProductDependency; + package = 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */; + productName = Base58Swift; + }; 18D65DFF2EB964B500252335 /* VssRustClientFfi */ = { isa = XCSwiftPackageProductDependency; package = 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2bf66c102..557302eed 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "746cacd44d171f1c992b592ea1224d0562117bffd2907a54dee9d441395ac6b4", + "originHash" : "51f054936b33d846460247543fc66f705845d9ac042d7a45c72b167d6171bac9", "pins" : [ + { + "identity" : "base58swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/keefertaylor/Base58Swift", + "state" : { + "revision" : "1c13ea6b07f1584660526f8bde3d4cc150e91acd", + "version" : "2.1.14" + } + }, + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, { "identity" : "bitkit-core", "kind" : "remoteSourceControl", @@ -24,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "1cd8f08fbe4c1814cfcba88f279c00bf2ebc71ac", - "version" : "0.7.0-rc.54" + "revision" : "6cfa59000a35e7c31130df5f0ddb0951779e495f", + "version" : "0.7.0-rc.55" } }, { @@ -42,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "aa15cae9c18f7a8643162d127320aa006ffb1afd", - "version" : "0.1.0-rc33" + "revision" : "2ff082c349b82a4becea23afac39164e202751c6", + "version" : "0.1.0-rc35" } }, { diff --git a/Bitkit/Components/SettingsRow.swift b/Bitkit/Components/SettingsRow.swift index af292a468..64ffdd886 100644 --- a/Bitkit/Components/SettingsRow.swift +++ b/Bitkit/Components/SettingsRow.swift @@ -23,6 +23,7 @@ enum SettingsRowRightIcon { struct SettingsRow: View { let title: String + let subtitle: String? let iconName: String? let iconColor: Color? let rightText: String? @@ -33,6 +34,7 @@ struct SettingsRow: View { init( title: String, + subtitle: String? = nil, iconName: String? = nil, iconColor: Color? = .brandAccent, rightText: String? = nil, @@ -42,6 +44,7 @@ struct SettingsRow: View { testIdentifier: String? = nil ) { self.title = title + self.subtitle = subtitle self.iconName = iconName self.iconColor = iconColor self.rightText = rightText @@ -59,9 +62,17 @@ struct SettingsRow: View { .padding(.trailing, 8) } - BodyMText(title, textColor: .textPrimary) - .lineLimit(1) - .truncationMode(.tail) + VStack(alignment: .leading, spacing: 4) { + BodyMText(title, textColor: .textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + if let subtitle { + BodySText(subtitle, textColor: .textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + } Spacer() @@ -98,7 +109,7 @@ struct SettingsRow: View { } } } - .frame(height: 50) + .frame(minHeight: subtitle == nil ? 50 : 66) CustomDivider() } diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 28debd942..e3f38fef1 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -10,6 +10,7 @@ struct WalletBackupV1: Codable { let privatePaykitHighestReservedReceiveIndexByAddressType: [String: UInt32]? let paykitSdkBackupState: String? let watchOnlyAccounts: [WatchOnlyAccountRecord]? + let watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState? } struct MetadataBackupV1: Codable { diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index 80d52eb98..b5a353e12 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -10,6 +10,7 @@ enum PubkyAuthClaim: String, Equatable { enum PubkyAuthRequestError: Error, Equatable { case invalidUrl + case missingBitkitClaim case duplicateBitkitClaim case unsupportedBitkitClaim(String) case invalidBitkitClaimCapabilities @@ -69,7 +70,12 @@ struct PubkyAuthRequest { guard claimValues.count <= 1 else { throw PubkyAuthRequestError.duplicateBitkitClaim } - guard let claimValue = claimValues.first else { return nil } + guard let claimValue = claimValues.first else { + if capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities { + throw PubkyAuthRequestError.missingBitkitClaim + } + return nil + } guard let claim = PubkyAuthClaim(rawValue: claimValue) else { throw PubkyAuthRequestError.unsupportedBitkitClaim(claimValue) } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 7ed5441e8..f2141443a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -669,11 +669,9 @@ "pubky_auth__watch_only_account_default_name" = "{service} account"; "pubky_auth__watch_only_account_fallback_name" = "Paykit server account"; "pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; -"pubky_auth__watch_only_auth_request_error" = "This authorization request is missing a valid secret."; "pubky_auth__watch_only_account_name_label" = "ACCOUNT NAME"; "pubky_auth__watch_only_account_name_placeholder" = "Name this account"; "pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub."; -"pubky_auth__watch_only_transport_unavailable" = "This request needs the signed watch-only claim transport from the Paykit SDK before it can be authorized."; "pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky."; "pubky_auth__authorizing" = "Authorizing..."; "pubky_auth__success_title" = "Authorization Successful"; @@ -687,15 +685,20 @@ "pubky_auth__use_ring_desc" = "Your identity was created with Pubky Ring. Open Ring to approve this request."; "pubky_auth__invalid_request" = "Invalid auth request"; "pubky_auth__approval_failed" = "Authorization Failed"; +"watch_only_accounts__active_section" = "Active accounts"; "watch_only_accounts__copy_xpub" = "Copy xpub"; -"watch_only_accounts__description" = "Each approved Paykit server gets a separate Bitcoin account. Turning tracking off rebuilds the wallet without that account until you turn it on again."; +"watch_only_accounts__description" = "Each approved Paykit server gets a separate Bitcoin account. Turn tracking off to unload an account without deleting its wallet history."; +"watch_only_accounts__details_title" = "Account details"; "watch_only_accounts__empty_description" = "Accounts appear here after you approve a Paykit server setup request."; "watch_only_accounts__empty_title" = "No server accounts"; "watch_only_accounts__name" = "ACCOUNT NAME"; "watch_only_accounts__name_placeholder" = "Account name"; "watch_only_accounts__name_saved" = "Account name saved"; -"watch_only_accounts__pending_delivery" = "Setup pending: the signed xpub claim has not been delivered."; +"watch_only_accounts__pending_description" = "These accounts were created locally, but setup did not finish. Retry the same authorization to reuse the account."; +"watch_only_accounts__pending_section" = "Incomplete setup"; "watch_only_accounts__save_name" = "Save name"; +"watch_only_accounts__setup_not_confirmed" = "Setup not confirmed"; +"watch_only_accounts__setup_not_finished" = "Setup did not finish. Retry the same authorization to use this account."; "watch_only_accounts__title" = "Server Accounts"; "watch_only_accounts__tracking" = "Track account"; "watch_only_accounts__tracking_disabled" = "Account tracking disabled"; diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index d7f678659..1b0218a56 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -207,8 +207,12 @@ class BackupService { let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) - try WatchOnlyAccountStore.restore(payload.watchOnlyAccounts) - await WatchOnlyAccountManager.shared.reload() + try WatchOnlyAccountStore.restore( + payload.watchOnlyAccounts, + allocationState: payload.watchOnlyAccountAllocationState + ) + try await WatchOnlyAccountManager.shared.reload() + try await LightningService.shared.reconcileWatchOnlyAccounts() pendingPaykitSdkBackupState = payload.paykitSdkBackupState didRestoreWalletBackup = true @@ -713,13 +717,15 @@ class BackupService { let transfers = try TransferStorage.shared.getAll() let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot() let paykitSdkBackupState = try await PrivatePaykitService.shared.backupSnapshot() + let watchOnlyAccountSnapshot = try WatchOnlyAccountStore.backupSnapshot() let payload = WalletBackupV1( version: 1, createdAt: UInt64(Date().timeIntervalSince1970 * 1000), transfers: transfers, privatePaykitHighestReservedReceiveIndexByAddressType: privatePaykitHighestReservedReceiveIndexByAddressType, paykitSdkBackupState: paykitSdkBackupState, - watchOnlyAccounts: WatchOnlyAccountStore.load() + watchOnlyAccounts: watchOnlyAccountSnapshot.accounts, + watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 666ae387c..d2c4de285 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -6,6 +6,8 @@ import LDKNode // TODO: catch all errors and pass a readable error message to the UI class LightningService { + private static let watchOnlyAccountHighestPreRevealedAddressIndex: UInt32 = 999 + private var node: Node? var currentWalletIndex: Int = 0 @@ -85,6 +87,9 @@ class LightningService { let (selectedAddressType, monitoredTypes) = Self.addressTypeStateFromUserDefaults() config.addressType = selectedAddressType config.addressTypesToMonitor = monitoredTypes.filter { $0 != selectedAddressType } +#if !BITKIT_NOTIFICATION_EXTENSION + config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) +#endif let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) @@ -97,7 +102,9 @@ class LightningService { lightningWalletSyncIntervalSecs: Env.walletSyncIntervalSecs, feeRateCacheUpdateIntervalSecs: Env.walletSyncIntervalSecs ), - connectionTimeoutSecs: 10 + connectionTimeoutSecs: 10, + additionalWalletFullScanBatchSize: 100, + additionalWalletFullScanStopGap: 1000 ) builder.setChainSourceElectrum(serverUrl: resolvedElectrumServerUrl, config: electrumConfig) @@ -169,8 +176,6 @@ class LightningService { } } - try await registerEnabledWatchOnlyAccounts(walletIndex: walletIndex) - Logger.info("LDK node setup") // Clear memory @@ -259,6 +264,14 @@ class LightningService { try node.start() } +#if !BITKIT_NOTIFICATION_EXTENSION + do { + try await reconcileWatchOnlyAccounts() + } catch { + Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") + } +#endif + await refreshChannelCache() await refreshCache() @@ -460,6 +473,10 @@ class LightningService { throw AppError(serviceError: .nodeNotSetup) } +#if !BITKIT_NOTIFICATION_EXTENSION + try await reconcileWatchOnlyAccounts() +#endif + Logger.debug("Syncing LDK...") try await ServiceQueue.background(.ldk) { try node.syncWallets() @@ -481,54 +498,117 @@ class LightningService { } } - func createAndTrackWatchOnlyAccount(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String { + func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) } return try await ServiceQueue.background(.ldk) { - let xpub = try node.exportOnchainWalletAccountXpub(addressType: addressType, accountIndex: accountIndex) - let isAlreadyTracked = node.listOnchainWalletAccounts().contains { - $0.addressType == addressType && $0.accountIndex == accountIndex - } - if !isAlreadyTracked { - try node.addOnchainWalletAccount(addressType: addressType, accountIndex: accountIndex, xpub: xpub) - } - try node.syncWallets() - return xpub + try node.exportOnchainWalletAccountXpub(addressType: addressType, accountIndex: accountIndex) } } - private func registerEnabledWatchOnlyAccounts(walletIndex: Int) async throws { + func setWatchOnlyAccountTracking( + accountIndex: UInt32, + addressType: LDKNode.AddressType, + xpub: String, + enabled: Bool + ) async throws { guard let node else { throw AppError(serviceError: .nodeNotSetup) } - let records = WatchOnlyAccountStore.load().filter { - $0.walletIndex == walletIndex && $0.isTrackingEnabled + try await ServiceQueue.background(.ldk) { + let isTracked = node.listOnchainWalletAccounts().contains { + $0.addressType == addressType && $0.accountIndex == accountIndex + } + + if enabled { + var didAddAccount = false + do { + if !isTracked { + try node.addOnchainWalletAccount(addressType: addressType, accountIndex: accountIndex, xpub: xpub) + didAddAccount = true + } + try node.onchainPayment().revealReceiveAddressesToAccount( + addressType: addressType, + accountIndex: accountIndex, + index: Self.watchOnlyAccountHighestPreRevealedAddressIndex + ) + if didAddAccount { + try node.syncWallets() + } + } catch { + if didAddAccount { + do { + try node.removeOnchainWalletAccount(addressType: addressType, accountIndex: accountIndex) + } catch let cleanupError { + Logger.error(cleanupError, context: "Failed to roll back Paykit Server account tracking") + } + } + throw error + } + } else if !enabled, isTracked { + try node.removeOnchainWalletAccount(addressType: addressType, accountIndex: accountIndex) + } } - guard !records.isEmpty else { return } + } + +#if !BITKIT_NOTIFICATION_EXTENSION + func reconcileWatchOnlyAccounts() async throws { + guard let node else { return } + let allRecords = try WatchOnlyAccountStore.load().filter { $0.walletIndex == currentWalletIndex } + let desiredConfigs = try Self.watchOnlyAccountConfigs(walletIndex: currentWalletIndex) try await ServiceQueue.background(.ldk) { - let registered = Set(node.listOnchainWalletAccounts().map { - "\($0.addressType)-\($0.accountIndex)" - }) + let trackedAccounts = node.listOnchainWalletAccounts() + let managedKeys = Set(allRecords.map { "\($0.addressType):\($0.accountIndex)" }) + let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) + + for trackedAccount in trackedAccounts { + let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" + if managedKeys.contains(key), !desiredKeys.contains(key) { + try node.removeOnchainWalletAccount( + addressType: trackedAccount.addressType, + accountIndex: trackedAccount.accountIndex + ) + } + } - for record in records { - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey + for config in desiredConfigs { + let isTracked = trackedAccounts.contains { + $0.addressType == config.addressType && $0.accountIndex == config.accountIndex } - let key = "\(addressType)-\(record.accountIndex)" - if !registered.contains(key) { + if !isTracked { try node.addOnchainWalletAccount( - addressType: addressType, - accountIndex: record.accountIndex, - xpub: record.xpub + addressType: config.addressType, + accountIndex: config.accountIndex, + xpub: config.xpub ) } + try node.onchainPayment().revealReceiveAddressesToAccount( + addressType: config.addressType, + accountIndex: config.accountIndex, + index: Self.watchOnlyAccountHighestPreRevealedAddressIndex + ) + } + + } + } + + private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { + try WatchOnlyAccountStore.enabledAccounts(for: walletIndex).map { record in + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey } + return OnchainWalletAccountConfig( + addressType: addressType, + accountIndex: record.accountIndex, + xpub: record.xpub + ) } } +#endif func newAddress() async throws -> String { guard let node else { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 6be3f56ca..d4c3f720c 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -95,6 +95,27 @@ enum PubkyService { ) } + static func approveAuthWithCompanionClaim(authUrl: String, unsignedPayload: Data, secretKeyHex: String) async throws { + try await PaykitSdkService.shared.approveAuthWithCompanionClaim( + authUrl: authUrl, + expectedCapabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, + secretKeyHex: secretKeyHex, + claim: Paykit.PubkyAuthCompanionClaim( + queryParameter: PubkyAuthClaim.queryParameter, + claimType: PubkyAuthClaim.watchOnlyAccountV1.rawValue, + unsignedPayload: unsignedPayload + ) + ) + } + + static func didDeliverCompanionClaim(error: Error) -> Bool { + guard let approvalError = error as? Paykit.PubkyAuthCompanionClaimApprovalError else { return false } + if case .AuthorizationFailure = approvalError { + return true + } + return false + } + // MARK: - Key Derivation /// Derive an Ed25519 secret key from a BIP39 mnemonic. Returns hex-encoded 32-byte key. @@ -334,6 +355,22 @@ actor PaykitSdkService { } } + func approveAuthWithCompanionClaim( + authUrl: String, + expectedCapabilities: String, + secretKeyHex: String, + claim: Paykit.PubkyAuthCompanionClaim + ) async throws { + try await operationLock.withLock { + try await bootstrap().approveAuthWithCompanionClaim( + authUrl: authUrl, + expectedCapabilities: expectedCapabilities, + localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + claim: claim + ) + } + } + func fetchFile(uri: String) async throws -> Data { try await operationLock.withLock { guard let data = try await handle().fetchPubkyFile(uri: uri) else { diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 21904a0a4..2baac1d09 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -1,3 +1,4 @@ +import Base58Swift import Combine import CryptoKit import Foundation @@ -5,6 +6,7 @@ import LDKNode enum WatchOnlyAccountSetupState: String, Codable { case pendingDelivery + case authorizing case active } @@ -28,63 +30,196 @@ struct WatchOnlyAccountRecord: Codable, Equatable, Identifiable { enum WatchOnlyAccountError: LocalizedError, Equatable { case invalidAccountName - case invalidAuthRequest case invalidExtendedPublicKey - case companionTransportUnavailable var errorDescription: String? { switch self { case .invalidAccountName: t("pubky_auth__watch_only_account_name_error") - case .invalidAuthRequest: - t("pubky_auth__watch_only_auth_request_error") case .invalidExtendedPublicKey: t("pubky_auth__watch_only_account_xpub_error") - case .companionTransportUnavailable: - t("pubky_auth__watch_only_transport_unavailable") } } } -protocol WatchOnlyAccountClaimTransport: Sendable { - func deliver(payload: Data, authUrl: String) async throws +protocol WatchOnlyAccountNodeHandling: AnyObject { + var currentWalletIndex: Int { get } + func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String + func setWatchOnlyAccountTracking(accountIndex: UInt32, addressType: LDKNode.AddressType, xpub: String, enabled: Bool) async throws } -struct UnavailableWatchOnlyAccountClaimTransport: WatchOnlyAccountClaimTransport { - func deliver(payload _: Data, authUrl _: String) async throws { - throw WatchOnlyAccountError.companionTransportUnavailable - } +extension LightningService: WatchOnlyAccountNodeHandling {} + +struct WatchOnlyAccountAllocationState: Codable, Equatable { + var highestAccountIndexByWallet: [String: UInt32] = [:] + var pendingAccountIndexByRequest: [String: UInt32] = [:] } -protocol WatchOnlyAccountNodeHandling: AnyObject { - var currentWalletIndex: Int { get } - func createAndTrackWatchOnlyAccount(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String +private struct WatchOnlyAccountData: Codable { + var accounts: [WatchOnlyAccountRecord] = [] + var allocationState = WatchOnlyAccountAllocationState() } -extension LightningService: WatchOnlyAccountNodeHandling {} +struct WatchOnlyAccountBackupSnapshot { + let accounts: [WatchOnlyAccountRecord] + let allocationState: WatchOnlyAccountAllocationState +} enum WatchOnlyAccountStore { static let walletBackupDataChangedPublisher = walletBackupDataChangedSubject.eraseToAnyPublisher() - private static let defaultsKey = "watchOnlyAccountsV1" + static let dataKey = "watchOnlyAccountDataV1" + + private static let legacyAccountsKey = "watchOnlyAccountsV1" + private static let legacyAllocationKey = "watchOnlyAccountAllocationsV1" private static let walletBackupDataChangedSubject = PassthroughSubject() - static func load(defaults: UserDefaults = .standard) -> [WatchOnlyAccountRecord] { - guard let data = defaults.data(forKey: defaultsKey), - let records = try? JSONDecoder().decode([WatchOnlyAccountRecord].self, from: data) - else { return [] } + static func load(defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { + try loadData(defaults: defaults).accounts.sorted { $0.accountIndex < $1.accountIndex } + } - return records.sorted { $0.accountIndex < $1.accountIndex } + static func enabledAccounts(for walletIndex: Int, defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { + try load(defaults: defaults).filter { + $0.walletIndex == walletIndex + && ($0.setupState == .active || $0.setupState == .authorizing) + && $0.isTrackingEnabled + } } static func save(_ records: [WatchOnlyAccountRecord], defaults: UserDefaults = .standard) throws { - let data = try JSONEncoder().encode(records) - defaults.set(data, forKey: defaultsKey) + var data = try loadData(defaults: defaults) + data.accounts = records.sorted { $0.accountIndex < $1.accountIndex } + data.allocationState.reconcileAccountIndexes(records) + try saveData(data, defaults: defaults) + } + + static func backupSnapshot(defaults: UserDefaults = .standard) throws -> WatchOnlyAccountBackupSnapshot { + let data = try loadData(defaults: defaults) + return WatchOnlyAccountBackupSnapshot( + accounts: data.accounts.sorted { $0.accountIndex < $1.accountIndex }, + allocationState: data.allocationState + ) + } + + static func restore( + _ records: [WatchOnlyAccountRecord]?, + allocationState restoredAllocationState: WatchOnlyAccountAllocationState? = nil, + defaults: UserDefaults = .standard + ) throws { + let restoredRecords = records ?? [] + var data = (try? loadData(defaults: defaults)) ?? WatchOnlyAccountData() + data.accounts = restoredRecords.sorted { $0.accountIndex < $1.accountIndex } + + var highestAccountIndexByWallet = data.allocationState.highestAccountIndexByWallet + + if let restoredAllocationState { + for (walletKey, restoredIndex) in restoredAllocationState.highestAccountIndexByWallet { + highestAccountIndexByWallet[walletKey] = max( + highestAccountIndexByWallet[walletKey] ?? 0, + restoredIndex + ) + } + } + + data.allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: highestAccountIndexByWallet, + pendingAccountIndexByRequest: restoredAllocationState?.pendingAccountIndexByRequest ?? [:] + ) + + data.allocationState.reconcileAccountIndexes(restoredRecords) + try saveData(data, defaults: defaults) + } + + static func reserveAccountIndex(walletIndex: Int, requestFingerprint: String, defaults: UserDefaults = .standard) throws -> UInt32 { + var data = try loadData(defaults: defaults) + let requestKey = allocationRequestKey(walletIndex: walletIndex, requestFingerprint: requestFingerprint) + if let pendingAccountIndex = data.allocationState.pendingAccountIndexByRequest[requestKey] { + return pendingAccountIndex + } + + let walletKey = String(walletIndex) + let highestPersistedAccountIndex = data.accounts + .filter { $0.walletIndex == walletIndex } + .map(\.accountIndex) + .max() ?? 0 + let highestAccountIndex = max( + data.allocationState.highestAccountIndexByWallet[walletKey] ?? 0, + highestPersistedAccountIndex + ) + guard highestAccountIndex < UInt32(Int32.max) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + let accountIndex = highestAccountIndex + 1 + data.allocationState.highestAccountIndexByWallet[walletKey] = accountIndex + data.allocationState.pendingAccountIndexByRequest[requestKey] = accountIndex + try saveData(data, defaults: defaults) + return accountIndex + } + + static func completeAllocation(walletIndex: Int, requestFingerprint: String, defaults: UserDefaults = .standard) throws { + var data = try loadData(defaults: defaults) + data.allocationState.pendingAccountIndexByRequest.removeValue( + forKey: allocationRequestKey(walletIndex: walletIndex, requestFingerprint: requestFingerprint) + ) + try saveData(data, defaults: defaults) + } + + static func markSetupActive(id: UUID, defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { + var data = try loadData(defaults: defaults) + guard let index = data.accounts.firstIndex(where: { $0.id == id }) else { + return data.accounts + } + + data.accounts[index].setupState = .active + data.accounts[index].isTrackingEnabled = true + let record = data.accounts[index] + data.allocationState.pendingAccountIndexByRequest.removeValue( + forKey: allocationRequestKey(walletIndex: record.walletIndex, requestFingerprint: record.requestFingerprint) + ) + try saveData(data, defaults: defaults) + return data.accounts.sorted { $0.accountIndex < $1.accountIndex } + } + + private static func loadData(defaults: UserDefaults) throws -> WatchOnlyAccountData { + if let encoded = defaults.data(forKey: dataKey) { + return try JSONDecoder().decode(WatchOnlyAccountData.self, from: encoded) + } + + let legacyAccountsData = defaults.data(forKey: legacyAccountsKey) + let legacyAllocationData = defaults.data(forKey: legacyAllocationKey) + guard legacyAccountsData != nil || legacyAllocationData != nil else { + return WatchOnlyAccountData() + } + + let accounts = try legacyAccountsData.map { try JSONDecoder().decode([WatchOnlyAccountRecord].self, from: $0) } ?? [] + var allocationState = try legacyAllocationData.map { + try JSONDecoder().decode(WatchOnlyAccountAllocationState.self, from: $0) + } ?? WatchOnlyAccountAllocationState() + allocationState.reconcileAccountIndexes(accounts) + + let migrated = WatchOnlyAccountData(accounts: accounts, allocationState: allocationState) + try saveData(migrated, defaults: defaults) + return migrated + } + + private static func saveData(_ data: WatchOnlyAccountData, defaults: UserDefaults) throws { + try defaults.set(JSONEncoder().encode(data), forKey: dataKey) walletBackupDataChangedSubject.send() } - static func restore(_ records: [WatchOnlyAccountRecord]?, defaults: UserDefaults = .standard) throws { - try save(records ?? [], defaults: defaults) + private static func allocationRequestKey(walletIndex: Int, requestFingerprint: String) -> String { + "\(walletIndex):\(requestFingerprint)" + } +} + +private extension WatchOnlyAccountAllocationState { + mutating func reconcileAccountIndexes(_ accounts: [WatchOnlyAccountRecord]) { + for (walletIndex, walletAccounts) in Dictionary(grouping: accounts, by: \WatchOnlyAccountRecord.walletIndex) { + guard let accountIndex = walletAccounts.map(\.accountIndex).max() else { continue } + let walletKey = String(walletIndex) + highestAccountIndexByWallet[walletKey] = max(highestAccountIndexByWallet[walletKey] ?? 0, accountIndex) + } } } @@ -92,78 +227,146 @@ enum WatchOnlyAccountStore { @MainActor final class WatchOnlyAccountManager { static let shared = WatchOnlyAccountManager() + private static let companionClaimQueryParameter = "x-bitkit-claim" private(set) var accounts: [WatchOnlyAccountRecord] private let defaults: UserDefaults private let node: WatchOnlyAccountNodeHandling - private let transport: WatchOnlyAccountClaimTransport + private var preparationTasks: [String: Task<(WatchOnlyAccountRecord, Data), Error>] = [:] init( defaults: UserDefaults = .standard, - node: WatchOnlyAccountNodeHandling = LightningService.shared, - transport: WatchOnlyAccountClaimTransport = UnavailableWatchOnlyAccountClaimTransport() + node: WatchOnlyAccountNodeHandling = LightningService.shared ) { self.defaults = defaults self.node = node - self.transport = transport - accounts = WatchOnlyAccountStore.load(defaults: defaults) + do { + accounts = try WatchOnlyAccountStore.load(defaults: defaults) + } catch { + accounts = [] + Logger.error("Failed to load watch-only account state: \(error)", context: "WatchOnlyAccountManager") + } } func accounts(for walletIndex: Int) -> [WatchOnlyAccountRecord] { accounts.filter { $0.walletIndex == walletIndex } } - func prepareSignedClaim(authUrl: String, name: String, secretKeyHex: String) async throws -> (WatchOnlyAccountRecord, Data) { + func prepareUnsignedClaim(authUrl: String, name: String) async throws -> (WatchOnlyAccountRecord, Data) { let normalizedName = try Self.normalizedName(name) let fingerprint = Self.requestFingerprint(authUrl) + let walletIndex = node.currentWalletIndex + let taskKey = "\(walletIndex):\(fingerprint)" + + if let preparationTask = preparationTasks[taskKey] { + return try await preparationTask.value + } - if let existing = accounts.first(where: { - $0.walletIndex == node.currentWalletIndex && $0.requestFingerprint == fingerprint + let preparationTask = Task { @MainActor in + try await self.prepareUnsignedClaim( + normalizedName: normalizedName, + fingerprint: fingerprint, + walletIndex: walletIndex + ) + } + preparationTasks[taskKey] = preparationTask + defer { preparationTasks[taskKey] = nil } + return try await preparationTask.value + } + + private func prepareUnsignedClaim( + normalizedName: String, + fingerprint: String, + walletIndex: Int + ) async throws -> (WatchOnlyAccountRecord, Data) { + if let existingIndex = accounts.firstIndex(where: { + $0.walletIndex == walletIndex + && $0.requestFingerprint == fingerprint + && $0.setupState != .active }) { - if existing.name != normalizedName { - try rename(id: existing.id, name: normalizedName) + if accounts[existingIndex].name != normalizedName { + accounts[existingIndex].name = normalizedName + try persist() } - let refreshed = accounts.first(where: { $0.id == existing.id }) ?? existing - let payload = try WatchOnlyAccountClaimCodec.encode(record: refreshed, authUrl: authUrl, secretKeyHex: secretKeyHex) - return (refreshed, payload) + let refreshed = accounts[existingIndex] + return try (refreshed, WatchOnlyAccountClaimCodec.encode(record: refreshed)) } - let walletAccounts = accounts(for: node.currentWalletIndex) - let highestAccountIndex = walletAccounts.map(\.accountIndex).max() ?? 0 - guard highestAccountIndex < UInt32(Int32.max) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey - } - let (accountIndex, overflow) = highestAccountIndex.addingReportingOverflow(1) - guard !overflow else { throw WatchOnlyAccountError.invalidExtendedPublicKey } + let accountIndex = try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: walletIndex, + requestFingerprint: fingerprint, + defaults: defaults + ) let addressType = LDKNode.AddressType.nativeSegwit - let xpub = try await node.createAndTrackWatchOnlyAccount(accountIndex: accountIndex, addressType: addressType) + let xpub = try await node.exportWatchOnlyAccountXpub(accountIndex: accountIndex, addressType: addressType) let record = WatchOnlyAccountRecord( id: UUID(), - walletIndex: node.currentWalletIndex, + walletIndex: walletIndex, accountIndex: accountIndex, addressType: addressType.stringValue, xpub: xpub, requestFingerprint: fingerprint, createdAt: UInt64(Date().timeIntervalSince1970 * 1000), name: normalizedName, - isTrackingEnabled: true, + isTrackingEnabled: false, setupState: .pendingDelivery ) accounts.append(record) try persist() - return try (record, WatchOnlyAccountClaimCodec.encode(record: record, authUrl: authUrl, secretKeyHex: secretKeyHex)) + return try (record, WatchOnlyAccountClaimCodec.encode(record: record)) + } + + func beginSetupAuthorization(id: UUID) async throws { + guard let index = accounts.firstIndex(where: { $0.id == id && $0.setupState != .active }) else { return } + let record = accounts[index] + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + try await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: true + ) + accounts[index].setupState = .authorizing + accounts[index].isTrackingEnabled = true + do { + try persist() + } catch { + try? await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: false + ) + accounts[index] = record + throw error + } } - func deliver(payload: Data, authUrl: String) async throws { - try await transport.deliver(payload: payload, authUrl: authUrl) + func cancelSetupAuthorization(id: UUID) async throws { + guard let index = accounts.firstIndex(where: { $0.id == id && $0.setupState != .active }) else { return } + let record = accounts[index] + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + accounts[index].setupState = .pendingDelivery + accounts[index].isTrackingEnabled = false + try persist() + try await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: false + ) } func markSetupActive(id: UUID) throws { - guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } - accounts[index].setupState = .active - try persist() + accounts = try WatchOnlyAccountStore.markSetupActive(id: id, defaults: defaults) } func rename(id: UUID, name: String) throws { @@ -173,14 +376,27 @@ final class WatchOnlyAccountManager { try persist() } - func setTrackingEnabled(id: UUID, enabled: Bool) throws { + func setTrackingEnabled(id: UUID, enabled: Bool) async throws { guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } + let record = accounts[index] + guard record.setupState == .active else { return } + guard record.isTrackingEnabled != enabled else { return } + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + try await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: enabled + ) accounts[index].isTrackingEnabled = enabled try persist() } - func reload() { - accounts = WatchOnlyAccountStore.load(defaults: defaults) + func reload() throws { + accounts = try WatchOnlyAccountStore.load(defaults: defaults) } private func persist() throws { @@ -197,7 +413,32 @@ final class WatchOnlyAccountManager { } private static func requestFingerprint(_ authUrl: String) -> String { - Data(SHA256.hash(data: Data(authUrl.utf8))).base64EncodedString() + guard let components = URLComponents(string: authUrl), + let scheme = components.scheme, + let host = components.host, + let relay = singleQueryValue(named: "relay", in: components), + let secret = singleQueryValue(named: "secret", in: components), + let capabilities = singleQueryValue(named: "caps", in: components), + let claim = singleQueryValue(named: companionClaimQueryParameter, in: components) + else { + return Data(SHA256.hash(data: Data(authUrl.utf8))).base64EncodedString() + } + let fingerprintSource = [ + scheme.lowercased(), + host.lowercased(), + components.path, + relay, + secret, + capabilities, + claim, + ].joined(separator: "\0") + return Data(SHA256.hash(data: Data(fingerprintSource.utf8))).base64EncodedString() + } + + private static func singleQueryValue(named name: String, in components: URLComponents) -> String? { + let values = components.queryItems?.filter { $0.name == name }.compactMap(\.value) ?? [] + guard values.count == 1, !values[0].isEmpty else { return nil } + return values[0] } } @@ -205,11 +446,9 @@ enum WatchOnlyAccountClaimCodec { static let version: UInt8 = 1 static let nativeSegwitAddressType: UInt8 = 0 static let serializedXpubLength = 78 - static let payloadLength = 1 + 4 + 1 + serializedXpubLength + 64 - - private static let signatureDomain = Data("x-bitkit-claim|watch-only-account-v1|".utf8) + static let payloadLength = 1 + 4 + 1 + serializedXpubLength - static func encode(record: WatchOnlyAccountRecord, authUrl: String, secretKeyHex: String) throws -> Data { + static func encode(record: WatchOnlyAccountRecord) throws -> Data { guard record.addressType == LDKNode.AddressType.nativeSegwit.stringValue else { throw WatchOnlyAccountError.invalidExtendedPublicKey } @@ -219,71 +458,16 @@ enum WatchOnlyAccountClaimCodec { claim.append(contentsOf: withUnsafeBytes(of: record.accountIndex.bigEndian, Array.init)) claim.append(nativeSegwitAddressType) claim.append(rawXpub) - - let privateKeyBytes = secretKeyHex.trimmingCharacters(in: .whitespacesAndNewlines).hexaData - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyBytes) - let requestSecretHash = try requestSecretHash(authUrl: authUrl) - let signature = try privateKey.signature(for: signatureDomain + requestSecretHash + claim) - claim.append(signature) return claim } - static func requestSecretHash(authUrl: String) throws -> Data { - guard let components = URLComponents(string: authUrl) else { - throw WatchOnlyAccountError.invalidAuthRequest - } - let secrets = components.queryItems?.filter { $0.name == "secret" }.compactMap(\.value) ?? [] - guard secrets.count == 1, let secret = secrets.first, !secret.isEmpty else { - throw WatchOnlyAccountError.invalidAuthRequest - } - return Data(SHA256.hash(data: Data(secret.utf8))) - } - static func serializedXpub(_ xpub: String) throws -> Data { - let decoded = try Base58Check.decode(xpub) - guard decoded.count == serializedXpubLength else { - throw WatchOnlyAccountError.invalidExtendedPublicKey - } - return decoded - } -} - -private enum Base58Check { - private static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") - private static let alphabetIndexes = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, $0) }) - - static func decode(_ value: String) throws -> Data { - guard !value.isEmpty else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - - var bytes = [UInt8](repeating: 0, count: 1) - for character in value { - guard let digit = alphabetIndexes[character] else { - throw WatchOnlyAccountError.invalidExtendedPublicKey - } - - var carry = digit - for index in bytes.indices.reversed() { - carry += Int(bytes[index]) * 58 - bytes[index] = UInt8(carry & 0xFF) - carry >>= 8 - } - while carry > 0 { - bytes.insert(UInt8(carry & 0xFF), at: 0) - carry >>= 8 - } - } - - let leadingZeros = value.prefix { $0 == "1" }.count - bytes = Array(repeating: 0, count: leadingZeros) + Array(bytes.drop { $0 == 0 }) - guard bytes.count > 4 else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - - let payload = Data(bytes.dropLast(4)) - let checksum = Data(bytes.suffix(4)) - let firstHash = SHA256.hash(data: payload) - let expectedChecksum = Data(SHA256.hash(data: Data(firstHash))).prefix(4) - guard checksum == expectedChecksum else { + guard xpub.count > 4, + let decoded = Base58.base58CheckDecode(xpub), + decoded.count == serializedXpubLength + else { throw WatchOnlyAccountError.invalidExtendedPublicKey } - return payload + return Data(decoded) } } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index fda98b449..92b106701 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -46,7 +46,7 @@ enum AppReset { if let bundleID = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleID) } - WatchOnlyAccountManager.shared.reload() + try WatchOnlyAccountManager.shared.reload() // Singleton retains stale @AppStorage values after removePersistentDomain SettingsViewModel.shared.resetToDefaults() diff --git a/Bitkit/Utilities/Errors.swift b/Bitkit/Utilities/Errors.swift index 5c0ecab37..91b09bdac 100644 --- a/Bitkit/Utilities/Errors.swift +++ b/Bitkit/Utilities/Errors.swift @@ -231,6 +231,9 @@ struct AppError: LocalizedError { case let .OnchainTxCreationFailed(message: ldkMessage): message = "Failed to create onchain transaction" debugMessage = ldkMessage + case let .OnchainWalletAccountNotRegistered(message: ldkMessage): + message = "Onchain wallet account is not registered" + debugMessage = ldkMessage case let .ConnectionFailed(message: ldkMessage): message = "Failed to connect to node" debugMessage = ldkMessage diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index f9b48a6d7..42b17a2c4 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -417,24 +417,6 @@ class WalletViewModel: ObservableObject { syncState() } - func reloadWatchOnlyAccountTracking() async throws { - nodeLifecycleState = .starting - let electrumServerUrl = electrumConfigService.getCurrentServer().fullUrl - let rgsServerUrl = rgsConfigService.getCurrentServerUrl() - - do { - try await lightningService.restart( - electrumServerUrl: electrumServerUrl, - rgsServerUrl: rgsServerUrl.isEmpty ? nil : rgsServerUrl - ) - nodeLifecycleState = .running - await refreshAndSyncState() - } catch { - nodeLifecycleState = .errorStarting(cause: error) - throw error - } - } - func createInvoice(amountSats: UInt64? = nil, note: String, expirySecs: UInt32? = nil) async throws -> String { let finalExpirySecs = expirySecs ?? 60 * 60 * 24 let invoice = try await lightningService.receive(amountSats: amountSats, description: note, expirySecs: finalExpirySecs) diff --git a/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift index 4c6020262..becca0c60 100644 --- a/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift +++ b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift @@ -2,31 +2,68 @@ import SwiftUI struct WatchOnlyAccountsView: View { @EnvironmentObject private var app: AppViewModel - @EnvironmentObject private var wallet: WalletViewModel @State private var manager = WatchOnlyAccountManager.shared - @State private var nameDrafts: [UUID: String] = [:] + @State private var selectedAccount: WatchOnlyAccountRecord? @State private var updatingAccountId: UUID? private var visibleAccounts: [WatchOnlyAccountRecord] { manager.accounts(for: LightningService.shared.currentWalletIndex) } + private var activeAccounts: [WatchOnlyAccountRecord] { + visibleAccounts.filter { $0.setupState == .active } + } + + private var pendingAccounts: [WatchOnlyAccountRecord] { + visibleAccounts.filter { $0.setupState != .active } + } + var body: some View { VStack(spacing: 0) { NavigationBar(title: t("watch_only_accounts__title")) .padding(.horizontal, 16) ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { BodyMText(t("watch_only_accounts__description"), textColor: .white64) .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 16) + .padding(.bottom, 8) if visibleAccounts.isEmpty { emptyState } else { - ForEach(visibleAccounts) { account in - accountCard(account) + if !activeAccounts.isEmpty { + SettingsSectionHeader(t("watch_only_accounts__active_section").localizedUppercase) + + ForEach(activeAccounts) { account in + accountSummaryRow(account) + + SettingsRow( + title: t("watch_only_accounts__tracking"), + rightIcon: nil, + toggle: trackingBinding(for: account), + disabled: updatingAccountId != nil, + testIdentifier: "WatchOnlyAccountTracking_\(account.accountIndex)" + ) + } + } + + if !pendingAccounts.isEmpty { + CaptionMText(t("watch_only_accounts__pending_section").localizedUppercase, textColor: .yellow) + .frame(height: 50) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, activeAccounts.isEmpty ? 0 : 16) + + BodySText(t("watch_only_accounts__pending_description"), textColor: .white64) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 8) + + ForEach(pendingAccounts) { account in + accountSummaryRow(account) + } } } } @@ -37,8 +74,17 @@ struct WatchOnlyAccountsView: View { .background(Color.customBlack) .navigationBarHidden(true) .task { - manager.reload() - nameDrafts = Dictionary(uniqueKeysWithValues: visibleAccounts.map { ($0.id, $0.name) }) + do { + try manager.reload() + } catch { + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + } + } + .sheet(item: $selectedAccount) { account in + WatchOnlyAccountDetailsSheet(account: account) { name in + try manager.rename(id: account.id, name: name) + } + .environmentObject(app) } } @@ -55,57 +101,100 @@ struct WatchOnlyAccountsView: View { .accessibilityIdentifier("WatchOnlyAccountsEmpty") } - private func accountCard(_ account: WatchOnlyAccountRecord) -> some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - BodyMSBText(account.name) - CaptionText(account.derivationPath, textColor: .white64) - } - - Spacer() + private func accountSummaryRow(_ account: WatchOnlyAccountRecord) -> some View { + Button { + selectedAccount = account + } label: { + SettingsRow( + title: account.name, + subtitle: account.derivationPath, + rightText: account.setupState != .active ? t("watch_only_accounts__setup_not_confirmed") : nil + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("WatchOnlyAccount_\(account.accountIndex)") + } - Toggle( - "", - isOn: Binding( - get: { account.isTrackingEnabled }, - set: { enabled in - Task { await updateTracking(account: account, enabled: enabled) } - } - ) - ) - .labelsHidden() - .toggleStyle(SwitchToggleStyle(tint: .brandAccent)) - .disabled(updatingAccountId != nil) - .accessibilityLabel(t("watch_only_accounts__tracking")) - .accessibilityIdentifier("WatchOnlyAccountTracking_\(account.accountIndex)") + private func trackingBinding(for account: WatchOnlyAccountRecord) -> Binding { + Binding( + get: { + manager.accounts.first(where: { $0.id == account.id })?.isTrackingEnabled ?? account.isTrackingEnabled + }, + set: { enabled in + Task { await updateTracking(account: account, enabled: enabled) } } + ) + } - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("watch_only_accounts__name"), textColor: .white64) - TextField( - t("watch_only_accounts__name_placeholder"), - text: Binding( - get: { nameDrafts[account.id] ?? account.name }, - set: { nameDrafts[account.id] = $0 } - ), - testIdentifier: "WatchOnlyAccountName_\(account.accountIndex)", - submitLabel: .done - ) - .onSubmit { saveName(account) } - } + @MainActor + private func updateTracking(account: WatchOnlyAccountRecord, enabled: Bool) async { + updatingAccountId = account.id + + do { + try await manager.setTrackingEnabled(id: account.id, enabled: enabled) + app.toast( + type: .success, + title: enabled ? t("watch_only_accounts__tracking_enabled") : t("watch_only_accounts__tracking_disabled") + ) + } catch { + app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) + } + + updatingAccountId = nil + } +} + +private struct WatchOnlyAccountDetailsSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var app: AppViewModel + + let account: WatchOnlyAccountRecord + let onRename: (String) throws -> Void + + @State private var name: String + + init(account: WatchOnlyAccountRecord, onRename: @escaping (String) throws -> Void) { + self.account = account + self.onRename = onRename + _name = State(initialValue: account.name) + } - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("watch_only_accounts__xpub"), textColor: .white64) - BodySText(account.xpub, textColor: .white64) - .lineLimit(2) - .truncationMode(.middle) - .accessibilityIdentifier("WatchOnlyAccountXpub_\(account.accountIndex)") + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("watch_only_accounts__details_title")) + + if account.setupState != .active { + BodyMText(t("watch_only_accounts__setup_not_finished"), textColor: .yellow) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + .accessibilityIdentifier("WatchOnlyAccountPending_\(account.accountIndex)") } + CaptionMText(t("watch_only_accounts__name"), textColor: .white64) + .padding(.bottom, 8) + + TextField( + t("watch_only_accounts__name_placeholder"), + text: $name, + testIdentifier: "WatchOnlyAccountName_\(account.accountIndex)", + submitLabel: .done + ) + .onSubmit(saveName) + + CaptionMText(t("watch_only_accounts__xpub"), textColor: .white64) + .padding(.top, 24) + .padding(.bottom, 8) + + BodySText(account.xpub, textColor: .white64) + .lineLimit(3) + .truncationMode(.middle) + .accessibilityIdentifier("WatchOnlyAccountXpub_\(account.accountIndex)") + + Spacer(minLength: 24) + HStack(spacing: 12) { CustomButton(title: t("watch_only_accounts__save_name"), variant: .secondary) { - saveName(account) + saveName() } .accessibilityIdentifier("WatchOnlyAccountSaveName_\(account.accountIndex)") @@ -115,56 +204,30 @@ struct WatchOnlyAccountsView: View { } .accessibilityIdentifier("WatchOnlyAccountCopyXpub_\(account.accountIndex)") } - - if account.setupState == .pendingDelivery { - CaptionText(t("watch_only_accounts__pending_delivery"), textColor: .yellow) - .accessibilityIdentifier("WatchOnlyAccountPending_\(account.accountIndex)") - } + .padding(.bottom, 24) } - .padding(20) - .background(Color.gray6) - .cornerRadius(16) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("WatchOnlyAccount_\(account.accountIndex)") + .padding(.horizontal, 16) + .background(Color.customBlack) + .presentationDetents([.height(520)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(32) } - @MainActor - private func saveName(_ account: WatchOnlyAccountRecord) { + private func saveName() { do { - try manager.rename(id: account.id, name: nameDrafts[account.id] ?? account.name) + try onRename(name) app.toast(type: .success, title: t("watch_only_accounts__name_saved")) + dismiss() } catch { app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) } } - - @MainActor - private func updateTracking(account: WatchOnlyAccountRecord, enabled: Bool) async { - let previousValue = account.isTrackingEnabled - updatingAccountId = account.id - - do { - try manager.setTrackingEnabled(id: account.id, enabled: enabled) - try await wallet.reloadWatchOnlyAccountTracking() - app.toast( - type: .success, - title: enabled ? t("watch_only_accounts__tracking_enabled") : t("watch_only_accounts__tracking_disabled") - ) - } catch { - try? manager.setTrackingEnabled(id: account.id, enabled: previousValue) - try? await wallet.reloadWatchOnlyAccountTracking() - app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) - } - - updatingAccountId = nil - } } #Preview { NavigationStack { WatchOnlyAccountsView() .environmentObject(AppViewModel()) - .environmentObject(WalletViewModel()) } .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index a81b8edd9..f5594138d 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -24,6 +24,62 @@ func resolvePubkyApprovalLocalAuthMode( return .none } +typealias OrdinaryPubkyAuthApproval = (String, String, String) async throws -> Void +typealias CompanionPubkyAuthApproval = (String, Data, String) async throws -> Void + +@MainActor +func approvePubkyAuthRequest( + request: PubkyAuthRequest, + authUrl: String, + accountName: String, + secretKeyHex: String, + accountManager: WatchOnlyAccountManager? = nil, + ordinaryApproval: @escaping OrdinaryPubkyAuthApproval = { authUrl, capabilities, secretKeyHex in + try await PubkyService.approveAuth( + authUrl: authUrl, + expectedCapabilities: capabilities, + secretKeyHex: secretKeyHex + ) + }, + companionApproval: @escaping CompanionPubkyAuthApproval = { authUrl, unsignedPayload, secretKeyHex in + try await PubkyService.approveAuthWithCompanionClaim( + authUrl: authUrl, + unsignedPayload: unsignedPayload, + secretKeyHex: secretKeyHex + ) + } +) async throws { + let accountManager = accountManager ?? .shared + if request.bitkitClaim == .watchOnlyAccountV1 { + let preparedClaim = try await accountManager.prepareUnsignedClaim(authUrl: authUrl, name: accountName) + do { + try await accountManager.beginSetupAuthorization(id: preparedClaim.0.id) + } catch { + do { + try await accountManager.cancelSetupAuthorization(id: preparedClaim.0.id) + } catch let cleanupError { + Logger.error("Failed to unload incomplete watch-only account: \(cleanupError)", context: "PubkyAuthApprovalSheet") + } + throw error + } + do { + try await companionApproval(authUrl, preparedClaim.1, secretKeyHex) + } catch { + if !PubkyService.didDeliverCompanionClaim(error: error) { + do { + try await accountManager.cancelSetupAuthorization(id: preparedClaim.0.id) + } catch let cleanupError { + Logger.error("Failed to unload incomplete watch-only account: \(cleanupError)", context: "PubkyAuthApprovalSheet") + } + } + throw error + } + try accountManager.markSetupActive(id: preparedClaim.0.id) + } else { + try await ordinaryApproval(authUrl, request.capabilities, secretKeyHex) + } +} + struct PubkyAuthApprovalConfig { let authUrl: String let request: PubkyAuthRequest @@ -50,10 +106,17 @@ struct PubkyAuthApprovalSheet: View { @State private var isShowingAuthCheck = false @State private var watchOnlyAccountName = "" - private enum ApprovalState { + enum ApprovalState { case authorize case authorizing case success + + @MainActor + mutating func beginAuthorization() -> Bool { + guard self == .authorize else { return false } + self = .authorizing + return true + } } private var headerTitle: String { @@ -101,27 +164,7 @@ struct PubkyAuthApprovalSheet: View { private var authorizeContent: some View { VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 32) - - permissionsSection - .padding(.bottom, 16) - - if config.request.bitkitClaim != nil { - bitkitClaimSection - .padding(.bottom, 16) - - watchOnlyAccountNameSection - .padding(.bottom, 16) - } - - Spacer() - - trustWarning - .padding(.bottom, 16) - - profileCard - .padding(.bottom, 24) + approvalDetails(disablesName: false) HStack(spacing: 16) { CustomButton(title: t("common__cancel"), variant: .secondary) { @@ -141,28 +184,7 @@ struct PubkyAuthApprovalSheet: View { private var authorizingContent: some View { VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 32) - - permissionsSection - .padding(.bottom, 16) - - if config.request.bitkitClaim != nil { - bitkitClaimSection - .padding(.bottom, 16) - - watchOnlyAccountNameSection - .disabled(true) - .padding(.bottom, 16) - } - - Spacer() - - trustWarning - .padding(.bottom, 16) - - profileCard - .padding(.bottom, 24) + approvalDetails(disablesName: true) CustomButton(title: t("pubky_auth__authorizing"), isLoading: true) {} .disabled(true) @@ -195,6 +217,34 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Shared Components + private func approvalDetails(disablesName: Bool) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + descriptionText + .padding(.bottom, 32) + + permissionsSection + .padding(.bottom, 16) + + if config.request.bitkitClaim != nil { + bitkitClaimSection + .padding(.bottom, 16) + + watchOnlyAccountNameSection + .disabled(disablesName) + .padding(.bottom, 16) + } + + trustWarning + .padding(.bottom, 16) + + profileCard + .padding(.bottom, 24) + } + } + .scrollIndicators(.hidden) + } + private var serviceText: String { config.request.serviceNames.joined(separator: " and ") } @@ -256,6 +306,7 @@ struct PubkyAuthApprovalSheet: View { CaptionMText(t("pubky_auth__watch_only_account_title"), textColor: .white64) BodySText(t("pubky_auth__watch_only_account_description")) .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) } .padding(16) .background(Color.gray6) @@ -349,7 +400,7 @@ struct PubkyAuthApprovalSheet: View { @MainActor private func confirmAuthorize() async { - state = .authorizing + guard state.beginAuthorization() else { return } do { guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), @@ -360,27 +411,13 @@ struct PubkyAuthApprovalSheet: View { return } - var preparedAccountId: UUID? - if config.request.bitkitClaim == .watchOnlyAccountV1 { - let preparedClaim = try await WatchOnlyAccountManager.shared.prepareSignedClaim( - authUrl: config.authUrl, - name: watchOnlyAccountName, - secretKeyHex: secretKey - ) - try await WatchOnlyAccountManager.shared.deliver(payload: preparedClaim.1, authUrl: config.authUrl) - preparedAccountId = preparedClaim.0.id - } - - try await PubkyService.approveAuth( + try await approvePubkyAuthRequest( + request: config.request, authUrl: config.authUrl, - expectedCapabilities: config.request.capabilities, + accountName: watchOnlyAccountName, secretKeyHex: secretKey ) - if let preparedAccountId { - try WatchOnlyAccountManager.shared.markSetupActive(id: preparedAccountId) - } - state = .success } catch { Logger.error("Failed to approve pubky auth: \(error)", context: "PubkyAuthApprovalSheet") diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index 3272af742..bd1c265aa 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -121,6 +121,7 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertNil(payload.privatePaykitHighestReservedReceiveIndexByAddressType) XCTAssertNil(payload.paykitSdkBackupState) XCTAssertNil(payload.watchOnlyAccounts) + XCTAssertNil(payload.watchOnlyAccountAllocationState) } func testWalletBackupRoundTripsPrivateReservationCeilingAndSdkState() throws { @@ -130,7 +131,8 @@ final class PrivatePaykitServiceTests: XCTestCase { transfers: [], privatePaykitHighestReservedReceiveIndexByAddressType: ["nativeSegwit": 5], paykitSdkBackupState: "AQID", - watchOnlyAccounts: nil + watchOnlyAccounts: nil, + watchOnlyAccountAllocationState: nil ) let data = try JSONEncoder().encode(backup) diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index 3e8f24e80..487a06748 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -1,4 +1,7 @@ +import Base58Swift @testable import Bitkit +import LDKNode +import Paykit import XCTest final class PubkyAuthApprovalSheetTests: XCTestCase { @@ -41,4 +44,291 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(mode, .none) } + + @MainActor + func testCompanionDeliveryFailureDoesNotApproveOrdinaryAuthOrActivateAccount() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + var ordinaryApprovalCount = 0 + var companionApprovalCount = 0 + + do { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + ordinaryApproval: { _, _, _ in ordinaryApprovalCount += 1 }, + companionApproval: { _, _, _ in + companionApprovalCount += 1 + throw ApprovalFakeError.deliveryFailed + } + ) + XCTFail("Expected companion approval to fail") + } catch ApprovalFakeError.deliveryFailed {} + + XCTAssertEqual(companionApprovalCount, 1) + XCTAssertEqual(ordinaryApprovalCount, 0) + XCTAssertEqual(manager.accounts.count, 1) + XCTAssertEqual(manager.accounts.first?.setupState, .pendingDelivery) + XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, false) + XCTAssertEqual(node.trackingChanges, [true, false]) + } + + @MainActor + func testCompanionDeliverySuccessActivatesAndKeepsAccountTracked() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in } + ) + + XCTAssertEqual(manager.accounts.first?.setupState, .active) + XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, true) + XCTAssertEqual(node.trackingChanges, [true]) + } + + @MainActor + func testDuplicateConfirmationRunsOneCompanionApprovalLifecycle() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + let companionApprovalGate = ApprovalCompanionGate() + let harness = ApprovalSingleFlightHarness( + request: request, + authUrl: authUrl, + manager: manager, + companionApprovalGate: companionApprovalGate + ) + + let firstConfirmation = Task { @MainActor in + try await harness.confirm() + } + await companionApprovalGate.waitUntilFirstApprovalStarts() + + do { + try await harness.confirm() + } catch { + await companionApprovalGate.releaseFirstApproval() + _ = try? await firstConfirmation.value + throw error + } + + let companionApprovalCount = await companionApprovalGate.approvalCount + XCTAssertEqual(companionApprovalCount, 1) + XCTAssertEqual(node.trackingChanges, [true]) + + await companionApprovalGate.releaseFirstApproval() + try await firstConfirmation.value + + XCTAssertEqual(manager.accounts.count, 1) + XCTAssertEqual(manager.accounts.first?.setupState, .active) + XCTAssertEqual(node.trackingChanges, [true]) + } + + @MainActor + func testNormalAuthorizationFailureAfterCompanionDeliveryKeepsAccountTracked() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + + await XCTAssertThrowsErrorAsync { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in + throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") + } + ) + } + + XCTAssertEqual(manager.accounts.first?.setupState, .authorizing) + XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, true) + XCTAssertEqual(node.trackingChanges, [true]) + } + + @MainActor + func testTrackingPreparationFailureUnloadsAccountBeforeApproval() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + node.failNextTrackingPreparation = true + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + var companionApprovalCount = 0 + + await XCTAssertThrowsErrorAsync { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in companionApprovalCount += 1 } + ) + } + + XCTAssertEqual(companionApprovalCount, 0) + XCTAssertEqual(manager.accounts.first?.setupState, .pendingDelivery) + XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, false) + XCTAssertEqual(node.trackingChanges, [true, false]) + } +} + +private enum ApprovalFakeError: Error { + case deliveryFailed + case trackingPreparationFailed +} + +@MainActor +private final class ApprovalSingleFlightHarness { + private var state: PubkyAuthApprovalSheet.ApprovalState = .authorize + private let request: Bitkit.PubkyAuthRequest + private let authUrl: String + private let manager: Bitkit.WatchOnlyAccountManager + private let companionApprovalGate: ApprovalCompanionGate + + init( + request: Bitkit.PubkyAuthRequest, + authUrl: String, + manager: Bitkit.WatchOnlyAccountManager, + companionApprovalGate: ApprovalCompanionGate + ) { + self.request = request + self.authUrl = authUrl + self.manager = manager + self.companionApprovalGate = companionApprovalGate + } + + func confirm() async throws { + guard state.beginAuthorization() else { return } + + do { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { [companionApprovalGate] _, _, _ in + await companionApprovalGate.approve() + } + ) + state = .success + } catch { + state = .authorize + throw error + } + } +} + +private actor ApprovalCompanionGate { + private var count = 0 + private var firstApprovalContinuation: CheckedContinuation? + private var firstApprovalStartWaiters: [CheckedContinuation] = [] + private var hasStartedFirstApproval = false + + var approvalCount: Int { + count + } + + func approve() async { + count += 1 + guard count == 1 else { return } + + hasStartedFirstApproval = true + let waiters = firstApprovalStartWaiters + firstApprovalStartWaiters.removeAll() + waiters.forEach { $0.resume() } + + await withCheckedContinuation { continuation in + firstApprovalContinuation = continuation + } + } + + func waitUntilFirstApprovalStarts() async { + guard !hasStartedFirstApproval else { return } + + await withCheckedContinuation { continuation in + firstApprovalStartWaiters.append(continuation) + } + } + + func releaseFirstApproval() { + firstApprovalContinuation?.resume() + firstApprovalContinuation = nil + } +} + +private final class ApprovalFakeWatchOnlyAccountNode: Bitkit.WatchOnlyAccountNodeHandling { + var currentWalletIndex = 0 + var failNextTrackingPreparation = false + private(set) var trackingChanges: [Bool] = [] + + func exportWatchOnlyAccountXpub(accountIndex _: UInt32, addressType _: LDKNode.AddressType) async throws -> String { + base58CheckEncode(Data((0 ..< Bitkit.WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8($0 + 1) })) + } + + func setWatchOnlyAccountTracking( + accountIndex _: UInt32, + addressType _: LDKNode.AddressType, + xpub _: String, + enabled: Bool + ) async throws { + trackingChanges.append(enabled) + if enabled, failNextTrackingPreparation { + failNextTrackingPreparation = false + throw ApprovalFakeError.trackingPreparationFailed + } + } + + private func base58CheckEncode(_ payload: Data) -> String { + Base58.base58CheckEncode([UInt8](payload)) + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> some Any, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch {} } diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 87a378c4a..4c309b2a6 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -32,6 +32,14 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertNil(request.bitkitClaim) } + func testParseUrlRejectsWatchOnlyCapabilityWithoutClaim() { + let url = authUrl(capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities) + + XCTAssertThrowsError(try PubkyAuthRequest.parse(url: url)) { + XCTAssertEqual($0 as? PubkyAuthRequestError, .missingBitkitClaim) + } + } + func testParseUrlRejectsDuplicateBitkitClaim() { let url = authUrl( capabilities: PubkyAuthClaim.watchOnlyAccountCapabilities, diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 990b89174..680437e82 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -1,17 +1,16 @@ +import Base58Swift @testable import Bitkit -import CryptoKit import LDKNode import XCTest final class WatchOnlyAccountServiceTests: XCTestCase { - func testSignedClaimContainsAccountMetadataAndVerifiableSignature() throws { - let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map(UInt8.init)) - let privateKeyBytes = Data(repeating: 7, count: 32) + func testUnsignedClaimContainsExactAccountMetadata() throws { + let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8($0 + 1) }) let record = makeRecord(accountIndex: 42, xpub: base58CheckEncode(rawXpub)) - let authUrl = "pubkyauth:///?secret=request-secret" - let payload = try WatchOnlyAccountClaimCodec.encode(record: record, authUrl: authUrl, secretKeyHex: privateKeyBytes.hex) + let payload = try WatchOnlyAccountClaimCodec.encode(record: record) + XCTAssertEqual(payload.count, 84) XCTAssertEqual(payload.count, WatchOnlyAccountClaimCodec.payloadLength) XCTAssertEqual(payload[0], WatchOnlyAccountClaimCodec.version) XCTAssertEqual(payload[5], WatchOnlyAccountClaimCodec.nativeSegwitAddressType) @@ -19,42 +18,275 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let accountIndex = payload[1 ..< 5].reduce(UInt32.zero) { ($0 << 8) | UInt32($1) } XCTAssertEqual(accountIndex, 42) + } + + func testUnsignedClaimRejectsInvalidBase58CheckChecksum() throws { + let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8($0 + 1) }) + let validXpub = base58CheckEncode(rawXpub) + let invalidXpub = String(validXpub.dropLast()) + (validXpub.last == "1" ? "2" : "1") + + XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.encode(record: makeRecord(accountIndex: 1, xpub: invalidXpub))) { + XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) + } + } + + func testRestoreDoesNotLowerAllocatorHighWaterMark() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "first", defaults: defaults), 1) + try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "first", defaults: defaults) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "second", defaults: defaults), 2) + try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "second", defaults: defaults) + + let restored = makeRecord(accountIndex: 1, xpub: base58CheckEncode(Data(repeating: 1, count: 78))) + try WatchOnlyAccountStore.restore([restored], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "next", defaults: defaults), 3) + } + + func testRestoreWithoutAllocatorClearsPendingReservationAndPreservesHighWaterMark() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "stale-pending", + defaults: defaults + ), 1) + + try WatchOnlyAccountStore.restore([], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "stale-pending", + defaults: defaults + ), 2) + } + + func testBackupRestoresPendingReservationAndHighWaterMark() throws { + let sourceSuiteName = "WatchOnlyAccountServiceTests.source.\(UUID().uuidString)" + let restoredSuiteName = "WatchOnlyAccountServiceTests.restored.\(UUID().uuidString)" + let sourceDefaults = try XCTUnwrap(UserDefaults(suiteName: sourceSuiteName)) + let restoredDefaults = try XCTUnwrap(UserDefaults(suiteName: restoredSuiteName)) + defer { + sourceDefaults.removePersistentDomain(forName: sourceSuiteName) + restoredDefaults.removePersistentDomain(forName: restoredSuiteName) + } + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: sourceDefaults + ), 1) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: sourceDefaults) + + try WatchOnlyAccountStore.restore( + snapshot.accounts, + allocationState: snapshot.allocationState, + defaults: restoredDefaults + ) + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: restoredDefaults + ), 1) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "next", + defaults: restoredDefaults + ), 2) + } + + func testCorruptedStateFailsClosedWithoutResettingAllocator() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let corruptedData = Data("not-json".utf8) + defaults.set(corruptedData, forKey: WatchOnlyAccountStore.dataKey) - let unsignedClaim = payload.prefix(84) - let signature = payload.suffix(64) - let requestSecretHash = try WatchOnlyAccountClaimCodec.requestSecretHash(authUrl: authUrl) - let signable = Data("x-bitkit-claim|watch-only-account-v1|".utf8) + requestSecretHash + unsignedClaim - let publicKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyBytes).publicKey - XCTAssertTrue(publicKey.isValidSignature(signature, for: signable)) + XCTAssertThrowsError(try WatchOnlyAccountStore.load(defaults: defaults)) + XCTAssertThrowsError(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "request", + defaults: defaults + )) + XCTAssertEqual(defaults.data(forKey: WatchOnlyAccountStore.dataKey), corruptedData) } - func testRequestSecretHashPercentDecodesWithoutTreatingPlusAsSpace() throws { - let hash = try WatchOnlyAccountClaimCodec.requestSecretHash( - authUrl: "pubkyauth://signin?secret=one%2Ftwo+three" + func testRestoreRepairsCorruptedLocalStateFromValidBackup() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(Data("not-json".utf8), forKey: WatchOnlyAccountStore.dataKey) + let restored = makeRecord( + accountIndex: 4, + xpub: base58CheckEncode(Data(repeating: 1, count: 78)), + setupState: .active + ) + + try WatchOnlyAccountStore.restore( + [restored], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 6], + pendingAccountIndexByRequest: ["0:pending": 6] + ), + defaults: defaults ) - XCTAssertEqual(hash, Data(SHA256.hash(data: Data("one/two+three".utf8)))) + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [restored]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: defaults + ), 6) + } + + func testStartupTrackingIncludesEnabledActiveAndAuthorizingAccountsForCurrentWallet() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + try WatchOnlyAccountStore.save( + [ + makeRecord(accountIndex: 1, xpub: xpub, setupState: .active), + makeRecord(accountIndex: 2, xpub: xpub, isTrackingEnabled: false, setupState: .active), + makeRecord(accountIndex: 3, xpub: xpub, walletIndex: 1, setupState: .active), + makeRecord(accountIndex: 4, xpub: xpub, setupState: .pendingDelivery), + makeRecord(accountIndex: 5, xpub: xpub, setupState: .authorizing), + ], + defaults: defaults + ) + + XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults).map(\.accountIndex), [1, 5]) } @MainActor - func testEachRequestGetsANewAccountAndRetryReusesPendingAccount() async throws { + func testEachSetupGetsANewAccountAndRetryReusesPendingAccount() async throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let node = FakeWatchOnlyAccountNode() let manager = WatchOnlyAccountManager(defaults: defaults, node: node) - let secret = Data(repeating: 9, count: 32).hex - let first = try await manager.prepareSignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Store one", secretKeyHex: secret) - let retry = try await manager.prepareSignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Changed", secretKeyHex: secret) - let second = try await manager.prepareSignedClaim(authUrl: "pubkyauth:///?secret=two", name: "Store two", secretKeyHex: secret) + let first = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Store one") + let retry = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Changed") + try manager.markSetupActive(id: first.0.id) + let repeatedAfterCompletion = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=one", + name: "Store one again" + ) + let second = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=two", name: "Store two") XCTAssertEqual(first.0.accountIndex, 1) XCTAssertEqual(retry.0.id, first.0.id) - XCTAssertEqual(second.0.accountIndex, 2) - XCTAssertEqual(node.createdAccountIndexes, [1, 2]) - XCTAssertEqual(WatchOnlyAccountStore.load(defaults: defaults).count, 2) + XCTAssertEqual(repeatedAfterCompletion.0.accountIndex, 2) + XCTAssertEqual(second.0.accountIndex, 3) + XCTAssertEqual(node.exportedAccountIndexes, [1, 2, 3]) + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults).count, 3) + } + + @MainActor + func testEquivalentAuthUrlsReuseTheSamePendingAccount() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + let first = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&secret=same&relay=https%3A%2F%2Frelay.test&x-bitkit-claim=watch-only-account-v1", + name: "First" + ) + let reordered = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth://signin?relay=https://relay.test&x-bitkit-claim=watch-only-account-v1&secret=s%61me&caps=/pub/paykit/v0/bitkit/server/:rw", + name: "Renamed" + ) + let differentRelay = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth://signin?relay=https://other-relay.test&x-bitkit-claim=watch-only-account-v1&secret=same&caps=/pub/paykit/v0/bitkit/server/:rw", + name: "Other relay" + ) + + XCTAssertEqual(reordered.0.id, first.0.id) + XCTAssertEqual(reordered.0.accountIndex, first.0.accountIndex) + XCTAssertEqual(differentRelay.0.accountIndex, 2) + XCTAssertEqual(node.exportedAccountIndexes, [1, 2]) + } + + @MainActor + func testConcurrentPreparationForTheSameRequestCreatesOneAccount() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + node.creationDelayNanoseconds = 20_000_000 + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + + async let first = manager.prepareUnsignedClaim(authUrl: "pubkyauth://signin?secret=same", name: "Account") + async let second = manager.prepareUnsignedClaim(authUrl: "pubkyauth://signin?secret=same", name: "Account") + let prepared = try await (first, second) + + XCTAssertEqual(prepared.0.0.id, prepared.1.0.id) + XCTAssertEqual(node.exportedAccountIndexes, [1]) + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults).count, 1) + } + + func testActivationAndReservationCompletionPersistAtomically() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let accountIndex = try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "request", + defaults: defaults + ) + let record = makeRecord( + accountIndex: accountIndex, + xpub: base58CheckEncode(Data(repeating: 1, count: 78)), + isTrackingEnabled: false + ) + try WatchOnlyAccountStore.save([record], defaults: defaults) + + let activeAccounts = try WatchOnlyAccountStore.markSetupActive(id: record.id, defaults: defaults) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + + XCTAssertEqual(activeAccounts.count, 1) + XCTAssertEqual(activeAccounts.first?.setupState, .active) + XCTAssertTrue(try XCTUnwrap(activeAccounts.first).isTrackingEnabled) + XCTAssertTrue(snapshot.allocationState.pendingAccountIndexByRequest.isEmpty) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "request", + defaults: defaults + ), 2) + } + + @MainActor + func testFailedCreationReservesAndReusesAccountIndex() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + node.failNextCreation = true + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + + await XCTAssertThrowsErrorAsync { + try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=retry", name: "Retry") + } + let retry = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=retry", name: "Retry") + let next = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=next", name: "Next") + + XCTAssertEqual(retry.0.accountIndex, 1) + XCTAssertEqual(next.0.accountIndex, 2) + XCTAssertEqual(node.exportedAccountIndexes, [1, 1, 2]) } @MainActor @@ -63,94 +295,114 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let manager = WatchOnlyAccountManager(defaults: defaults, node: FakeWatchOnlyAccountNode()) - let prepared = try await manager.prepareSignedClaim( + let node = FakeWatchOnlyAccountNode() + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + let prepared = try await manager.prepareUnsignedClaim( authUrl: "pubkyauth:///?secret=state", - name: "Original", - secretKeyHex: Data(repeating: 3, count: 32).hex + name: "Original" ) try manager.rename(id: prepared.0.id, name: "Creator shop") - try manager.setTrackingEnabled(id: prepared.0.id, enabled: false) + XCTAssertFalse(try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first).isTrackingEnabled) + try await manager.beginSetupAuthorization(id: prepared.0.id) try manager.markSetupActive(id: prepared.0.id) + try await manager.setTrackingEnabled(id: prepared.0.id, enabled: false) - let stored = try XCTUnwrap(WatchOnlyAccountStore.load(defaults: defaults).first) + let stored = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) XCTAssertEqual(stored.name, "Creator shop") XCTAssertFalse(stored.isTrackingEnabled) XCTAssertEqual(stored.setupState, .active) + XCTAssertEqual(node.trackingChanges, [.init(accountIndex: 1, enabled: true), .init(accountIndex: 1, enabled: false)]) + + let reloadedManager = WatchOnlyAccountManager(defaults: defaults, node: node) + try await reloadedManager.setTrackingEnabled(id: stored.id, enabled: true) + XCTAssertTrue(try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first).isTrackingEnabled) + XCTAssertEqual( + node.trackingChanges, + [ + .init(accountIndex: 1, enabled: true), + .init(accountIndex: 1, enabled: false), + .init(accountIndex: 1, enabled: true), + ] + ) } - private func makeRecord(accountIndex: UInt32, xpub: String) -> WatchOnlyAccountRecord { + private func makeRecord( + accountIndex: UInt32, + xpub: String, + walletIndex: Int = 0, + isTrackingEnabled: Bool = true, + setupState: WatchOnlyAccountSetupState = .pendingDelivery + ) -> WatchOnlyAccountRecord { WatchOnlyAccountRecord( id: UUID(), - walletIndex: 0, + walletIndex: walletIndex, accountIndex: accountIndex, addressType: LDKNode.AddressType.nativeSegwit.stringValue, xpub: xpub, requestFingerprint: "request", createdAt: 1000, name: "Test", - isTrackingEnabled: true, - setupState: .pendingDelivery + isTrackingEnabled: isTrackingEnabled, + setupState: setupState ) } private func base58CheckEncode(_ payload: Data) -> String { - let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") - let firstHash = SHA256.hash(data: payload) - let checksum = Data(SHA256.hash(data: Data(firstHash))).prefix(4) - let source = [UInt8](payload + checksum) - var digits = [Int](repeating: 0, count: 1) - - for byte in source { - var carry = Int(byte) - for index in digits.indices.reversed() { - carry += digits[index] << 8 - digits[index] = carry % 58 - carry /= 58 - } - while carry > 0 { - digits.insert(carry % 58, at: 0) - carry /= 58 - } - } - - let leadingZeros = source.prefix { $0 == 0 }.count - return String(repeating: "1", count: leadingZeros) + String(digits.drop { $0 == 0 }.map { alphabet[$0] }) + Base58.base58CheckEncode([UInt8](payload)) } } +private struct TrackingChange: Equatable { + let accountIndex: UInt32 + let enabled: Bool +} + +private enum FakeNodeError: Error { + case creationFailed +} + private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { var currentWalletIndex = 0 - private(set) var createdAccountIndexes: [UInt32] = [] + var failNextCreation = false + var creationDelayNanoseconds: UInt64 = 0 + private(set) var exportedAccountIndexes: [UInt32] = [] + private(set) var trackingChanges: [TrackingChange] = [] - func createAndTrackWatchOnlyAccount(accountIndex: UInt32, addressType _: LDKNode.AddressType) async throws -> String { - createdAccountIndexes.append(accountIndex) - let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8(($0 + Int(accountIndex)) % 256) }) + func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType _: LDKNode.AddressType) async throws -> String { + exportedAccountIndexes.append(accountIndex) + if creationDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: creationDelayNanoseconds) + } + if failNextCreation { + failNextCreation = false + throw FakeNodeError.creationFailed + } + let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8(($0 + Int(accountIndex)) % 255 + 1) }) return base58CheckEncode(rawXpub) } - private func base58CheckEncode(_ payload: Data) -> String { - let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") - let firstHash = SHA256.hash(data: payload) - let checksum = Data(SHA256.hash(data: Data(firstHash))).prefix(4) - let source = [UInt8](payload + checksum) - var digits = [Int](repeating: 0, count: 1) - - for byte in source { - var carry = Int(byte) - for index in digits.indices.reversed() { - carry += digits[index] << 8 - digits[index] = carry % 58 - carry /= 58 - } - while carry > 0 { - digits.insert(carry % 58, at: 0) - carry /= 58 - } - } + func setWatchOnlyAccountTracking( + accountIndex: UInt32, + addressType _: LDKNode.AddressType, + xpub _: String, + enabled: Bool + ) async throws { + trackingChanges.append(TrackingChange(accountIndex: accountIndex, enabled: enabled)) + } - let leadingZeros = source.prefix { $0 == 0 }.count - return String(repeating: "1", count: leadingZeros) + String(digits.drop { $0 == 0 }.map { alphabet[$0] }) + private func base58CheckEncode(_ payload: Data) -> String { + Base58.base58CheckEncode([UInt8](payload)) } } + +private func XCTAssertThrowsErrorAsync( + _ expression: () async throws -> some Any, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw", file: file, line: line) + } catch {} +} diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md index 7eb4f0f2f..03a7508e8 100644 --- a/Docs/watch-only-account-claim-v1.md +++ b/Docs/watch-only-account-claim-v1.md @@ -6,12 +6,13 @@ This document records the client contract implemented by Bitkit iOS and Android - The Pubky Auth URL includes `x-bitkit-claim=watch-only-account-v1`. - The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. -- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Retrying the same auth URL reuses its pending account. +- Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. +- Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. - The user assigns a local name to the account. The name is not disclosed in the claim. -## Signed claim +## Claim payload -The decrypted claim is 148 bytes: +Bitkit serializes this exact 84-byte unsigned payload: | Offset | Size | Value | | --- | ---: | --- | @@ -19,7 +20,8 @@ The decrypted claim is 148 bytes: | 1 | 4 | BIP account index, unsigned big-endian | | 5 | 1 | Address type, `0x00` for native SegWit | | 6 | 78 | Base58Check-decoded extended public key, including its 4-byte version | -| 84 | 64 | Ed25519 signature | + +Bitkit passes the payload to Paykit's `approveAuthWithCompanionClaim` API. Paykit appends a 64-byte Ed25519 signature, encrypts the resulting 148-byte claim, delivers it on the companion relay channel, and only then approves normal Pubky Auth. The signature input is the byte concatenation: @@ -35,14 +37,13 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - The normal AuthToken channel is `base_relay/{base64url_no_pad(BLAKE3(secret))}`. - The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. -- Bitkit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using the existing XSalsa20-Poly1305 format. -- Bitkit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. +- Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. +- Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. - Bitkit persists the account before delivery and retries the same pending claim idempotently. -- Disabling tracking rebuilds LDK Node without registering that account. It does not delete the xpub or revoke the server session. -- Account metadata is included in the existing encrypted wallet backup and uses the same JSON field names on iOS and Android. - -## Required shared protocol work - -The current Paykit app bindings do not expose the companion encrypted-relay operation, so both clients deliberately fail closed at delivery. Paykit must provide one shared binding that derives the domain-separated channel, encrypts, and posts the signed claim; duplicating Pubky relay cryptography in each app is not accepted. - -The server protocol must also communicate its highest issued external address index. Bitkit can then call LDK Node's account-specific reveal API before syncing. Without that high-water mark, addresses beyond the wallet lookahead cannot be guaranteed to appear, even though initial addresses work normally. +- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Success marks it active and leaves tracking enabled; preparation or companion-delivery failure returns it to pending and unloads it again. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. Retrying the same request can then finish normal authorization without losing visibility into addresses the server may already have derived. +- Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. +- Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. A v1 Paykit Server must not issue an index above `999`; supporting a higher index requires a future protocol signal that communicates the server's address high-water mark. +- Startup reconciliation restores runtime tracking and the pre-revealed range. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. +- Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. diff --git a/changelog.d/next/watch-only-account-claim.added.md b/changelog.d/next/watch-only-account-claim.added.md index 960bfa65c..6f6831858 100644 --- a/changelog.d/next/watch-only-account-claim.added.md +++ b/changelog.d/next/watch-only-account-claim.added.md @@ -1 +1 @@ -Bitkit now creates, names, signs, backs up, and manages separate watch-only Bitcoin accounts for approved Paykit server setup requests. +Bitkit now creates, names, backs up, and manages separate watch-only Bitcoin accounts and securely delivers signed setup claims to Paykit servers. From 1d3c99622339441f1202f18fa4c854e93b31303d Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 19:13:58 +0200 Subject: [PATCH 03/16] fix: finalize paykit account lifecycle --- Bitkit.xcodeproj/project.pbxproj | 4 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- Bitkit/Services/BackupService.swift | 4 +- Bitkit/Services/LightningService.swift | 126 +- Bitkit/Services/WatchOnlyAccountService.swift | 764 +++++++++-- Bitkit/Utilities/AppReset.swift | 2 +- .../Advanced/WatchOnlyAccountsView.swift | 19 +- .../PubkyAuthApprovalSheet.swift | 10 +- BitkitTests/PubkyAuthApprovalSheetTests.swift | 168 ++- .../WatchOnlyAccountServiceTests.swift | 1192 ++++++++++++++++- Docs/watch-only-account-claim-v1.md | 8 +- 11 files changed, 2107 insertions(+), 198 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 8a8454646..eafbf77b0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1177,7 +1177,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc35"; + version = "0.1.0-rc36"; }; }; 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */ = { @@ -1209,7 +1209,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.55"; + version = "0.7.0-rc.56"; }; }; 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 557302eed..7406f9a18 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "6cfa59000a35e7c31130df5f0ddb0951779e495f", - "version" : "0.7.0-rc.55" + "revision" : "b8c959c75b6922d6fa722ccf5492af0671b82a4b", + "version" : "0.7.0-rc.56" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "2ff082c349b82a4becea23afac39164e202751c6", - "version" : "0.1.0-rc35" + "revision" : "e0c71ce1b3850193b21b4fb3d9475ce09ac05822", + "version" : "0.1.0-rc36" } }, { diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index 1b0218a56..d88a33559 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -207,12 +207,10 @@ class BackupService { let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes) try TransferStorage.shared.upsertList(payload.transfers) await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType) - try WatchOnlyAccountStore.restore( + try await WatchOnlyAccountManager.shared.restore( payload.watchOnlyAccounts, allocationState: payload.watchOnlyAccountAllocationState ) - try await WatchOnlyAccountManager.shared.reload() - try await LightningService.shared.reconcileWatchOnlyAccounts() pendingPaykitSdkBackupState = payload.paykitSdkBackupState didRestoreWalletBackup = true diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index d2c4de285..13c88d518 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -87,9 +87,9 @@ class LightningService { let (selectedAddressType, monitoredTypes) = Self.addressTypeStateFromUserDefaults() config.addressType = selectedAddressType config.addressTypesToMonitor = monitoredTypes.filter { $0 != selectedAddressType } -#if !BITKIT_NOTIFICATION_EXTENSION - config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) -#endif + #if !BITKIT_NOTIFICATION_EXTENSION + config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) + #endif let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) @@ -264,13 +264,13 @@ class LightningService { try node.start() } -#if !BITKIT_NOTIFICATION_EXTENSION - do { - try await reconcileWatchOnlyAccounts() - } catch { - Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") - } -#endif + #if !BITKIT_NOTIFICATION_EXTENSION + do { + try await reconcileWatchOnlyAccounts() + } catch { + Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") + } + #endif await refreshChannelCache() await refreshCache() @@ -473,9 +473,9 @@ class LightningService { throw AppError(serviceError: .nodeNotSetup) } -#if !BITKIT_NOTIFICATION_EXTENSION - try await reconcileWatchOnlyAccounts() -#endif + #if !BITKIT_NOTIFICATION_EXTENSION + try await reconcileWatchOnlyAccounts() + #endif Logger.debug("Syncing LDK...") try await ServiceQueue.background(.ldk) { @@ -554,61 +554,76 @@ class LightningService { } } -#if !BITKIT_NOTIFICATION_EXTENSION - func reconcileWatchOnlyAccounts() async throws { - guard let node else { return } - let allRecords = try WatchOnlyAccountStore.load().filter { $0.walletIndex == currentWalletIndex } - let desiredConfigs = try Self.watchOnlyAccountConfigs(walletIndex: currentWalletIndex) + #if !BITKIT_NOTIFICATION_EXTENSION + func reconcileWatchOnlyAccounts() async throws { + try await WatchOnlyAccountManager.shared.reconcileTracking() + } - try await ServiceQueue.background(.ldk) { - let trackedAccounts = node.listOnchainWalletAccounts() - let managedKeys = Set(allRecords.map { "\($0.addressType):\($0.accountIndex)" }) - let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) - - for trackedAccount in trackedAccounts { - let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" - if managedKeys.contains(key), !desiredKeys.contains(key) { - try node.removeOnchainWalletAccount( - addressType: trackedAccount.addressType, - accountIndex: trackedAccount.accountIndex - ) - } + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) } + let walletRecords = records.filter { $0.walletIndex == currentWalletIndex } + let managedWalletRecords = managedRecords.filter { $0.walletIndex == currentWalletIndex } + let desiredConfigs = try Self.watchOnlyAccountConfigs(records: walletRecords) - for config in desiredConfigs { - let isTracked = trackedAccounts.contains { - $0.addressType == config.addressType && $0.accountIndex == config.accountIndex + try await ServiceQueue.background(.ldk) { + let trackedAccounts = node.listOnchainWalletAccounts() + let managedKeys = Set(managedWalletRecords.map { "\($0.addressType):\($0.accountIndex)" }) + let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) + + for trackedAccount in trackedAccounts { + let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" + if managedKeys.contains(key), !desiredKeys.contains(key) { + try node.removeOnchainWalletAccount( + addressType: trackedAccount.addressType, + accountIndex: trackedAccount.accountIndex + ) + } } - if !isTracked { - try node.addOnchainWalletAccount( + + for config in desiredConfigs { + let isTracked = trackedAccounts.contains { + $0.addressType == config.addressType && $0.accountIndex == config.accountIndex + } + if !isTracked { + try node.addOnchainWalletAccount( + addressType: config.addressType, + accountIndex: config.accountIndex, + xpub: config.xpub + ) + } + try node.onchainPayment().revealReceiveAddressesToAccount( addressType: config.addressType, accountIndex: config.accountIndex, - xpub: config.xpub + index: Self.watchOnlyAccountHighestPreRevealedAddressIndex ) } - try node.onchainPayment().revealReceiveAddressesToAccount( - addressType: config.addressType, - accountIndex: config.accountIndex, - index: Self.watchOnlyAccountHighestPreRevealedAddressIndex - ) } + } + private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { + try watchOnlyAccountConfigs(records: WatchOnlyAccountStore.enabledAccounts(for: walletIndex)) } - } - private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { - try WatchOnlyAccountStore.enabledAccounts(for: walletIndex).map { record in - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey + private static func watchOnlyAccountConfigs(records: [WatchOnlyAccountRecord]) throws -> [OnchainWalletAccountConfig] { + try records.filter { + ($0.setupState == .active || $0.setupState == .authorizing) && $0.isTrackingEnabled + }.map { record in + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + return OnchainWalletAccountConfig( + addressType: addressType, + accountIndex: record.accountIndex, + xpub: record.xpub + ) } - return OnchainWalletAccountConfig( - addressType: addressType, - accountIndex: record.accountIndex, - xpub: record.xpub - ) } - } -#endif + #endif func newAddress() async throws -> String { guard let node else { @@ -1382,6 +1397,7 @@ extension LightningService { Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService") } } + // Claimable payments remain pending until paymentSuccessful or paymentFailed updates their activity. case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, _, _): Logger.info( "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" @@ -1393,8 +1409,6 @@ extension LightningService { .info( "❌ Probe failed: paymentId: \(paymentId) paymentHash: \(paymentHash) shortChannelId: \(String(describing: shortChannelId))" ) - // Payment claimable doesn't need activity update - it's still pending - // The payment will be updated when it succeeds or fails via paymentSuccessful/paymentFailed events case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): Logger.info( "⏳ Channel pending: channelId: \(channelId) userChannelId: \(userChannelId) formerTemporaryChannelId: \(formerTemporaryChannelId) counterpartyNodeId: \(counterpartyNodeId) fundingTxo: \(fundingTxo)" diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 2baac1d09..fa312b105 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -28,12 +28,21 @@ struct WatchOnlyAccountRecord: Codable, Equatable, Identifiable { } } +struct WatchOnlyAccountAuthorizationAttempt: Equatable { + let accountId: UUID + fileprivate let token = UUID() +} + enum WatchOnlyAccountError: LocalizedError, Equatable { + case authorizationAccountMissing + case authorizationInProgress case invalidAccountName case invalidExtendedPublicKey var errorDescription: String? { switch self { + case .authorizationAccountMissing, .authorizationInProgress: + t("watch_only_accounts__setup_not_finished") case .invalidAccountName: t("pubky_auth__watch_only_account_name_error") case .invalidExtendedPublicKey: @@ -46,6 +55,12 @@ protocol WatchOnlyAccountNodeHandling: AnyObject { var currentWalletIndex: Int { get } func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String func setWatchOnlyAccountTracking(accountIndex: UInt32, addressType: LDKNode.AddressType, xpub: String, enabled: Bool) async throws + #if !BITKIT_NOTIFICATION_EXTENSION + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws + #endif } extension LightningService: WatchOnlyAccountNodeHandling {} @@ -58,6 +73,7 @@ struct WatchOnlyAccountAllocationState: Codable, Equatable { private struct WatchOnlyAccountData: Codable { var accounts: [WatchOnlyAccountRecord] = [] var allocationState = WatchOnlyAccountAllocationState() + var accountsPendingUnload: [WatchOnlyAccountRecord]? } struct WatchOnlyAccountBackupSnapshot { @@ -65,6 +81,11 @@ struct WatchOnlyAccountBackupSnapshot { let allocationState: WatchOnlyAccountAllocationState } +struct WatchOnlyAccountReconciliationSnapshot { + let accounts: [WatchOnlyAccountRecord] + let managedAccounts: [WatchOnlyAccountRecord] +} + enum WatchOnlyAccountStore { static let walletBackupDataChangedPublisher = walletBackupDataChangedSubject.eraseToAnyPublisher() @@ -72,6 +93,7 @@ enum WatchOnlyAccountStore { private static let legacyAccountsKey = "watchOnlyAccountsV1" private static let legacyAllocationKey = "watchOnlyAccountAllocationsV1" + private static let maximumAccountIndex = UInt32(Int32.max) private static let walletBackupDataChangedSubject = PassthroughSubject() static func load(defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { @@ -108,30 +130,146 @@ enum WatchOnlyAccountStore { ) throws { let restoredRecords = records ?? [] var data = (try? loadData(defaults: defaults)) ?? WatchOnlyAccountData() - data.accounts = restoredRecords.sorted { $0.accountIndex < $1.accountIndex } + let currentAccounts = data.accounts + let locallyManagedAccounts = currentAccounts + (data.accountsPendingUnload ?? []) + let authorizingAccounts = sanitizedAccounts(locallyManagedAccounts.filter { $0.setupState == .authorizing }) + let authorizingIds = Set(authorizingAccounts.map(\.id)) + let authorizingKeys = Set(authorizingAccounts.map(managementKey)) + let authorizingRequestKeys = Set(authorizingAccounts.map { + allocationRequestKey(walletIndex: $0.walletIndex, requestFingerprint: $0.requestFingerprint) + }) + let protectedRestorationConflicts = sanitizedAccounts(locallyManagedAccounts.filter { account in + account.setupState != .authorizing + && !authorizingIds.contains(account.id) + && !authorizingKeys.contains(managementKey(account)) + && (account.setupState == .active || !authorizingRequestKeys.contains( + allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) + )) + && shouldProtectLocalAccount(account, from: restoredRecords) + }).map { promotedTrackingState(for: $0, from: restoredRecords) } + let protectedLocalAccounts = authorizingAccounts + protectedRestorationConflicts + let protectedIds = Set(protectedLocalAccounts.map(\.id)) + let protectedKeys = Set(protectedLocalAccounts.map(managementKey)) + let protectedIncompleteRequestKeys = Set(protectedLocalAccounts.compactMap { account -> String? in + guard account.setupState != .active else { return nil } + return allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) + }) + let mergedRecords = sanitizedAccounts(restoredRecords.filter { + !protectedIds.contains($0.id) + && !protectedKeys.contains(managementKey($0)) + && ($0.setupState == .active || !protectedIncompleteRequestKeys.contains( + allocationRequestKey(walletIndex: $0.walletIndex, requestFingerprint: $0.requestFingerprint) + )) + }) + protectedLocalAccounts + let mergedKeys = Set(mergedRecords.map(managementKey)) + + let accountsPendingUnload = uniqueAccounts((data.accountsPendingUnload ?? []) + currentAccounts) + .filter { !mergedKeys.contains(managementKey($0)) } + data.accountsPendingUnload = accountsPendingUnload.isEmpty ? nil : accountsPendingUnload + data.accounts = mergedRecords.sorted { $0.accountIndex < $1.accountIndex } - var highestAccountIndexByWallet = data.allocationState.highestAccountIndexByWallet + var localAllocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: data.allocationState.highestAccountIndexByWallet.filter { + isValidAccountIndex($0.value) + }, + pendingAccountIndexByRequest: data.allocationState.pendingAccountIndexByRequest.filter { + isValidAccountIndex($0.value) + } + ) + localAllocationState.reconcileAccountIndexes(locallyManagedAccounts) + for (requestKey, accountIndex) in localAllocationState.pendingAccountIndexByRequest { + guard let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) else { continue } + let walletKey = String(walletIndex) + localAllocationState.highestAccountIndexByWallet[walletKey] = max( + localAllocationState.highestAccountIndexByWallet[walletKey] ?? 0, + accountIndex + ) + } + let localHighestAccountIndexByWallet = localAllocationState.highestAccountIndexByWallet + var highestAccountIndexByWallet = localHighestAccountIndexByWallet if let restoredAllocationState { for (walletKey, restoredIndex) in restoredAllocationState.highestAccountIndexByWallet { + guard isValidAccountIndex(restoredIndex) else { continue } highestAccountIndexByWallet[walletKey] = max( highestAccountIndexByWallet[walletKey] ?? 0, restoredIndex ) } + for (requestKey, restoredIndex) in restoredAllocationState.pendingAccountIndexByRequest { + guard isValidAccountIndex(restoredIndex), + let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) + else { continue } + let walletKey = String(walletIndex) + highestAccountIndexByWallet[walletKey] = max( + highestAccountIndexByWallet[walletKey] ?? 0, + restoredIndex + ) + } + } + + var pendingAccountIndexByRequest = validPendingAccountIndexes( + restoredAllocationState?.pendingAccountIndexByRequest ?? [:], + accounts: mergedRecords, + restoredAccounts: restoredRecords, + blockedAccounts: accountsPendingUnload, + localHighestAccountIndexByWallet: localHighestAccountIndexByWallet, + localPendingAccountIndexByRequest: localAllocationState.pendingAccountIndexByRequest + ) + for account in authorizingAccounts { + let accountSlot = allocationSlotKey(walletIndex: account.walletIndex, accountIndex: account.accountIndex) + pendingAccountIndexByRequest = pendingAccountIndexByRequest.filter { requestKey, accountIndex in + guard let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) else { return false } + return allocationSlotKey(walletIndex: walletIndex, accountIndex: accountIndex) != accountSlot + } + let requestKey = allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) + pendingAccountIndexByRequest[requestKey] = account.accountIndex + } + + for (requestKey, accountIndex) in pendingAccountIndexByRequest { + guard let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) else { continue } + let walletKey = String(walletIndex) + highestAccountIndexByWallet[walletKey] = max( + highestAccountIndexByWallet[walletKey] ?? 0, + accountIndex + ) } data.allocationState = WatchOnlyAccountAllocationState( highestAccountIndexByWallet: highestAccountIndexByWallet, - pendingAccountIndexByRequest: restoredAllocationState?.pendingAccountIndexByRequest ?? [:] + pendingAccountIndexByRequest: pendingAccountIndexByRequest ) - data.allocationState.reconcileAccountIndexes(restoredRecords) + data.allocationState.reconcileAccountIndexes(restoredRecords + mergedRecords + accountsPendingUnload) + try saveData(data, defaults: defaults) + } + + static func reconciliationSnapshot(defaults: UserDefaults = .standard) throws -> WatchOnlyAccountReconciliationSnapshot { + let data = try loadData(defaults: defaults) + return WatchOnlyAccountReconciliationSnapshot( + accounts: data.accounts.sorted { $0.accountIndex < $1.accountIndex }, + managedAccounts: uniqueAccounts(data.accounts + (data.accountsPendingUnload ?? [])) + ) + } + + static func finishReconciliation(walletIndex: Int, defaults: UserDefaults = .standard) throws { + var data = try loadData(defaults: defaults) + guard let accountsPendingUnload = data.accountsPendingUnload, + accountsPendingUnload.contains(where: { $0.walletIndex == walletIndex }) + else { return } + let remainingAccounts = accountsPendingUnload.filter { $0.walletIndex != walletIndex } + data.accountsPendingUnload = remainingAccounts.isEmpty ? nil : remainingAccounts try saveData(data, defaults: defaults) } static func reserveAccountIndex(walletIndex: Int, requestFingerprint: String, defaults: UserDefaults = .standard) throws -> UInt32 { var data = try loadData(defaults: defaults) + data.allocationState.highestAccountIndexByWallet = data.allocationState.highestAccountIndexByWallet.filter { + isValidAccountIndex($0.value) + } + data.allocationState.pendingAccountIndexByRequest = data.allocationState.pendingAccountIndexByRequest.filter { + isValidAccountIndex($0.value) + } let requestKey = allocationRequestKey(walletIndex: walletIndex, requestFingerprint: requestFingerprint) if let pendingAccountIndex = data.allocationState.pendingAccountIndexByRequest[requestKey] { return pendingAccountIndex @@ -139,14 +277,14 @@ enum WatchOnlyAccountStore { let walletKey = String(walletIndex) let highestPersistedAccountIndex = data.accounts - .filter { $0.walletIndex == walletIndex } + .filter { $0.walletIndex == walletIndex && isValidAccountIndex($0.accountIndex) } .map(\.accountIndex) .max() ?? 0 let highestAccountIndex = max( data.allocationState.highestAccountIndexByWallet[walletKey] ?? 0, highestPersistedAccountIndex ) - guard highestAccountIndex < UInt32(Int32.max) else { + guard highestAccountIndex < maximumAccountIndex else { throw WatchOnlyAccountError.invalidExtendedPublicKey } @@ -168,7 +306,7 @@ enum WatchOnlyAccountStore { static func markSetupActive(id: UUID, defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { var data = try loadData(defaults: defaults) guard let index = data.accounts.firstIndex(where: { $0.id == id }) else { - return data.accounts + throw WatchOnlyAccountError.authorizationAccountMissing } data.accounts[index].setupState = .active @@ -181,6 +319,12 @@ enum WatchOnlyAccountStore { return data.accounts.sorted { $0.accountIndex < $1.accountIndex } } + static func clear(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: dataKey) + defaults.removeObject(forKey: legacyAccountsKey) + defaults.removeObject(forKey: legacyAllocationKey) + } + private static func loadData(defaults: UserDefaults) throws -> WatchOnlyAccountData { if let encoded = defaults.data(forKey: dataKey) { return try JSONDecoder().decode(WatchOnlyAccountData.self, from: encoded) @@ -198,9 +342,7 @@ enum WatchOnlyAccountStore { } ?? WatchOnlyAccountAllocationState() allocationState.reconcileAccountIndexes(accounts) - let migrated = WatchOnlyAccountData(accounts: accounts, allocationState: allocationState) - try saveData(migrated, defaults: defaults) - return migrated + return WatchOnlyAccountData(accounts: accounts, allocationState: allocationState) } private static func saveData(_ data: WatchOnlyAccountData, defaults: UserDefaults) throws { @@ -211,18 +353,293 @@ enum WatchOnlyAccountStore { private static func allocationRequestKey(walletIndex: Int, requestFingerprint: String) -> String { "\(walletIndex):\(requestFingerprint)" } + + private static func walletIndex(fromAllocationRequestKey requestKey: String) -> Int? { + guard let separatorIndex = requestKey.firstIndex(of: ":"), + separatorIndex != requestKey.startIndex, + requestKey.index(after: separatorIndex) != requestKey.endIndex, + let walletIndex = Int(requestKey[..= 0 + else { return nil } + return walletIndex + } + + private static func allocationSlotKey(walletIndex: Int, accountIndex: UInt32) -> String { + "\(walletIndex):\(accountIndex)" + } + + private static func isValidAccountIndex(_ accountIndex: UInt32) -> Bool { + accountIndex > 0 && accountIndex <= maximumAccountIndex + } + + private static func validPendingAccountIndexes( + _ pendingAccountIndexes: [String: UInt32], + accounts: [WatchOnlyAccountRecord], + restoredAccounts: [WatchOnlyAccountRecord], + blockedAccounts: [WatchOnlyAccountRecord], + localHighestAccountIndexByWallet: [String: UInt32], + localPendingAccountIndexByRequest: [String: UInt32] + ) -> [String: UInt32] { + let accountsBySlot = Dictionary(grouping: accounts) { + allocationSlotKey(walletIndex: $0.walletIndex, accountIndex: $0.accountIndex) + } + let incompleteAccountSlotsByRequest: [String: String] = Dictionary( + uniqueKeysWithValues: accounts.compactMap { account -> (String, String)? in + guard account.setupState != .active else { return nil } + let requestKey = allocationRequestKey( + walletIndex: account.walletIndex, + requestFingerprint: account.requestFingerprint + ) + return (requestKey, allocationSlotKey(walletIndex: account.walletIndex, accountIndex: account.accountIndex)) + } + ) + let restoredIncompleteRequestKeys = Set(restoredAccounts.compactMap { account -> String? in + guard account.setupState != .active else { return nil } + return allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) + }) + let restoredAccountSlots = Set(restoredAccounts.map { + allocationSlotKey(walletIndex: $0.walletIndex, accountIndex: $0.accountIndex) + }) + let blockedSlots = Set(blockedAccounts.map { + allocationSlotKey(walletIndex: $0.walletIndex, accountIndex: $0.accountIndex) + }) + let candidates = pendingAccountIndexes.compactMap { requestKey, accountIndex -> (String, UInt32, String)? in + guard isValidAccountIndex(accountIndex), + let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) + else { return nil } + let slot = allocationSlotKey(walletIndex: walletIndex, accountIndex: accountIndex) + let incompleteAccountSlot = incompleteAccountSlotsByRequest[requestKey] + guard !restoredIncompleteRequestKeys.contains(requestKey) || incompleteAccountSlot != nil, + incompleteAccountSlot == nil || incompleteAccountSlot == slot, + !restoredAccountSlots.contains(slot) || incompleteAccountSlot == slot + else { return nil } + if let localAccountIndex = localPendingAccountIndexByRequest[requestKey], + localAccountIndex != accountIndex + { + return nil + } + let slotAccounts = accountsBySlot[slot] ?? [] + if slotAccounts.isEmpty { + guard !blockedSlots.contains(slot), + localPendingAccountIndexByRequest[requestKey] == accountIndex + || accountIndex > (localHighestAccountIndexByWallet[String(walletIndex)] ?? 0) + else { return nil } + } else { + guard slotAccounts.allSatisfy({ + $0.setupState != .active + && allocationRequestKey(walletIndex: $0.walletIndex, requestFingerprint: $0.requestFingerprint) == requestKey + }) else { return nil } + } + return (requestKey, accountIndex, slot) + } + + return Dictionary(grouping: candidates, by: { $0.2 }).values.reduce(into: [:]) { validIndexes, slotCandidates in + guard slotCandidates.count == 1, let candidate = slotCandidates.first else { return } + validIndexes[candidate.0] = candidate.1 + } + } + + private static func uniqueAccounts(_ accounts: [WatchOnlyAccountRecord]) -> [WatchOnlyAccountRecord] { + Dictionary(grouping: accounts, by: managementKey).values.compactMap(\.last).sorted { $0.accountIndex < $1.accountIndex } + } + + private static func sanitizedAccounts(_ accounts: [WatchOnlyAccountRecord]) -> [WatchOnlyAccountRecord] { + var ids = Set() + var managementKeys = Set() + var incompleteRequestKeys = Set() + + return accounts.filter(isUsableAccount).sorted(by: accountRestorationOrder).filter { account in + let accountManagementKey = managementKey(account) + let incompleteRequestKey = allocationRequestKey( + walletIndex: account.walletIndex, + requestFingerprint: account.requestFingerprint + ) + guard !ids.contains(account.id), + !managementKeys.contains(accountManagementKey), + account.setupState == .active || !incompleteRequestKeys.contains(incompleteRequestKey) + else { return false } + + ids.insert(account.id) + managementKeys.insert(accountManagementKey) + if account.setupState != .active { + incompleteRequestKeys.insert(incompleteRequestKey) + } + return true + }.map(normalizedTrackingState) + } + + private static func isUsableAccount(_ account: WatchOnlyAccountRecord) -> Bool { + account.walletIndex >= 0 + && isValidAccountIndex(account.accountIndex) + && account.addressType == LDKNode.AddressType.nativeSegwit.stringValue + && (try? WatchOnlyAccountClaimCodec.serializedXpub(account.xpub)) != nil + } + + private static func normalizedTrackingState(_ account: WatchOnlyAccountRecord) -> WatchOnlyAccountRecord { + var account = account + switch account.setupState { + case .pendingDelivery: + account.isTrackingEnabled = false + case .authorizing: + account.isTrackingEnabled = true + case .active: + break + } + return account + } + + private static func shouldProtectLocalAccount( + _ localAccount: WatchOnlyAccountRecord, + from restoredAccounts: [WatchOnlyAccountRecord] + ) -> Bool { + let conflicts = restoredAccounts.filter(isUsableAccount).filter { + managementKey($0) == managementKey(localAccount) || $0.id == localAccount.id + } + guard let highestRestoredPriority = conflicts.map({ setupStatePriority($0.setupState) }).max() else { + return false + } + let localPriority = setupStatePriority(localAccount.setupState) + if localPriority != highestRestoredPriority { + return localPriority > highestRestoredPriority + } + return conflicts.contains { + setupStatePriority($0.setupState) == highestRestoredPriority + && !hasSameOwner(localAccount, $0) + } + } + + private static func promotedTrackingState( + for localAccount: WatchOnlyAccountRecord, + from restoredAccounts: [WatchOnlyAccountRecord] + ) -> WatchOnlyAccountRecord { + guard localAccount.setupState == .active, + restoredAccounts.filter(isUsableAccount).contains(where: { + managementKey($0) == managementKey(localAccount) + && ($0.setupState == .authorizing || $0.setupState == .active && $0.isTrackingEnabled) + }) + else { return localAccount } + var localAccount = localAccount + localAccount.isTrackingEnabled = true + return localAccount + } + + private static func hasSameOwner(_ lhs: WatchOnlyAccountRecord, _ rhs: WatchOnlyAccountRecord) -> Bool { + managementKey(lhs) == managementKey(rhs) + && lhs.requestFingerprint == rhs.requestFingerprint + && lhs.xpub == rhs.xpub + } + + private static func accountRestorationOrder(_ lhs: WatchOnlyAccountRecord, _ rhs: WatchOnlyAccountRecord) -> Bool { + let lhsPriority = setupStatePriority(lhs.setupState) + let rhsPriority = setupStatePriority(rhs.setupState) + if lhsPriority != rhsPriority { return lhsPriority > rhsPriority } + if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt } + if lhs.walletIndex != rhs.walletIndex { return lhs.walletIndex < rhs.walletIndex } + if lhs.accountIndex != rhs.accountIndex { return lhs.accountIndex < rhs.accountIndex } + if lhs.addressType != rhs.addressType { return lhs.addressType < rhs.addressType } + if lhs.requestFingerprint != rhs.requestFingerprint { return lhs.requestFingerprint < rhs.requestFingerprint } + if lhs.id != rhs.id { return lhs.id.uuidString < rhs.id.uuidString } + if lhs.xpub != rhs.xpub { return lhs.xpub < rhs.xpub } + if lhs.name != rhs.name { return lhs.name < rhs.name } + return lhs.isTrackingEnabled && !rhs.isTrackingEnabled + } + + private static func setupStatePriority(_ state: WatchOnlyAccountSetupState) -> Int { + switch state { + case .active: return 3 + case .authorizing: return 2 + case .pendingDelivery: return 1 + } + } + + private static func managementKey(_ account: WatchOnlyAccountRecord) -> String { + "\(account.walletIndex):\(account.addressType):\(account.accountIndex)" + } } private extension WatchOnlyAccountAllocationState { mutating func reconcileAccountIndexes(_ accounts: [WatchOnlyAccountRecord]) { for (walletIndex, walletAccounts) in Dictionary(grouping: accounts, by: \WatchOnlyAccountRecord.walletIndex) { - guard let accountIndex = walletAccounts.map(\.accountIndex).max() else { continue } + guard let accountIndex = walletAccounts.map(\.accountIndex).filter({ + $0 > 0 && $0 <= UInt32(Int32.max) + }).max() else { continue } let walletKey = String(walletIndex) highestAccountIndexByWallet[walletKey] = max(highestAccountIndexByWallet[walletKey] ?? 0, accountIndex) } } } +final class WatchOnlyAccountLifecycleCoordinator: @unchecked Sendable { + static let shared = WatchOnlyAccountLifecycleCoordinator() + + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private let stateLock = NSLock() + private let onWaiterQueued: (@Sendable () -> Void)? + private var isLocked = false + private var waiters: [Waiter] = [] + + init(onWaiterQueued: (@Sendable () -> Void)? = nil) { + self.onWaiterQueued = onWaiterQueued + } + + func withLock(_ operation: () async throws -> T) async throws -> T { + try await acquire() + defer { release() } + try Task.checkCancellation() + return try await operation() + } + + private func acquire() async throws { + let waiterId = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + stateLock.lock() + if Task.isCancelled { + stateLock.unlock() + continuation.resume(throwing: CancellationError()) + } else if isLocked { + waiters.append(Waiter(id: waiterId, continuation: continuation)) + stateLock.unlock() + onWaiterQueued?() + } else { + isLocked = true + stateLock.unlock() + continuation.resume() + } + } + } onCancel: { + cancelWaiter(id: waiterId) + } + } + + private func cancelWaiter(id: UUID) { + stateLock.lock() + guard let index = waiters.firstIndex(where: { $0.id == id }) else { + stateLock.unlock() + return + } + let waiter = waiters.remove(at: index) + stateLock.unlock() + waiter.continuation.resume(throwing: CancellationError()) + } + + private func release() { + stateLock.lock() + if waiters.isEmpty { + isLocked = false + stateLock.unlock() + } else { + let next = waiters.removeFirst() + stateLock.unlock() + next.continuation.resume() + } + } +} + @Observable @MainActor final class WatchOnlyAccountManager { @@ -232,14 +649,19 @@ final class WatchOnlyAccountManager { private(set) var accounts: [WatchOnlyAccountRecord] private let defaults: UserDefaults + private let lifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator private let node: WatchOnlyAccountNodeHandling private var preparationTasks: [String: Task<(WatchOnlyAccountRecord, Data), Error>] = [:] + private var activeAuthorizationAttempt: WatchOnlyAccountAuthorizationAttempt? + private var preservesAuthorizingStateOnFailure = false init( defaults: UserDefaults = .standard, + lifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator = .shared, node: WatchOnlyAccountNodeHandling = LightningService.shared ) { self.defaults = defaults + self.lifecycleCoordinator = lifecycleCoordinator self.node = node do { accounts = try WatchOnlyAccountStore.load(defaults: defaults) @@ -280,122 +702,266 @@ final class WatchOnlyAccountManager { fingerprint: String, walletIndex: Int ) async throws -> (WatchOnlyAccountRecord, Data) { - if let existingIndex = accounts.firstIndex(where: { - $0.walletIndex == walletIndex - && $0.requestFingerprint == fingerprint - && $0.setupState != .active - }) { - if accounts[existingIndex].name != normalizedName { - accounts[existingIndex].name = normalizedName - try persist() + try await lifecycleCoordinator.withLock { + if let existingIndex = accounts.firstIndex(where: { + $0.walletIndex == walletIndex + && $0.requestFingerprint == fingerprint + && $0.setupState != .active + }) { + if accounts[existingIndex].name != normalizedName { + accounts[existingIndex].name = normalizedName + try persist() + } + let refreshed = accounts[existingIndex] + return try (refreshed, WatchOnlyAccountClaimCodec.encode(record: refreshed)) } - let refreshed = accounts[existingIndex] - return try (refreshed, WatchOnlyAccountClaimCodec.encode(record: refreshed)) - } - let accountIndex = try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: walletIndex, - requestFingerprint: fingerprint, - defaults: defaults - ) - let addressType = LDKNode.AddressType.nativeSegwit - let xpub = try await node.exportWatchOnlyAccountXpub(accountIndex: accountIndex, addressType: addressType) - let record = WatchOnlyAccountRecord( - id: UUID(), - walletIndex: walletIndex, - accountIndex: accountIndex, - addressType: addressType.stringValue, - xpub: xpub, - requestFingerprint: fingerprint, - createdAt: UInt64(Date().timeIntervalSince1970 * 1000), - name: normalizedName, - isTrackingEnabled: false, - setupState: .pendingDelivery - ) + let accountIndex = try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: walletIndex, + requestFingerprint: fingerprint, + defaults: defaults + ) + let addressType = LDKNode.AddressType.nativeSegwit + let xpub = try await node.exportWatchOnlyAccountXpub(accountIndex: accountIndex, addressType: addressType) + let record = WatchOnlyAccountRecord( + id: UUID(), + walletIndex: walletIndex, + accountIndex: accountIndex, + addressType: addressType.stringValue, + xpub: xpub, + requestFingerprint: fingerprint, + createdAt: UInt64(Date().timeIntervalSince1970 * 1000), + name: normalizedName, + isTrackingEnabled: false, + setupState: .pendingDelivery + ) - accounts.append(record) - try persist() - return try (record, WatchOnlyAccountClaimCodec.encode(record: record)) + accounts.append(record) + try persist() + return try (record, WatchOnlyAccountClaimCodec.encode(record: record)) + } } - func beginSetupAuthorization(id: UUID) async throws { - guard let index = accounts.firstIndex(where: { $0.id == id && $0.setupState != .active }) else { return } - let record = accounts[index] - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey + func acquireSetupAuthorizationAttempt(id: UUID) throws -> WatchOnlyAccountAuthorizationAttempt { + guard activeAuthorizationAttempt == nil else { + throw WatchOnlyAccountError.authorizationInProgress } + let attempt = WatchOnlyAccountAuthorizationAttempt(accountId: id) + activeAuthorizationAttempt = attempt + preservesAuthorizingStateOnFailure = accounts.first(where: { $0.id == id })?.setupState == .authorizing + return attempt + } - try await node.setWatchOnlyAccountTracking( - accountIndex: record.accountIndex, - addressType: addressType, - xpub: record.xpub, - enabled: true - ) - accounts[index].setupState = .authorizing - accounts[index].isTrackingEnabled = true - do { - try persist() - } catch { - try? await node.setWatchOnlyAccountTracking( + func beginSetupAuthorization(attempt: WatchOnlyAccountAuthorizationAttempt) async throws { + try requireActiveAuthorizationAttempt(attempt) + try await lifecycleCoordinator.withLock { + try requireActiveAuthorizationAttempt(attempt) + guard let record = accounts.first(where: { $0.id == attempt.accountId && $0.setupState != .active }) else { + throw WatchOnlyAccountError.authorizationAccountMissing + } + preservesAuthorizingStateOnFailure = preservesAuthorizingStateOnFailure || record.setupState == .authorizing + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + try await node.setWatchOnlyAccountTracking( accountIndex: record.accountIndex, addressType: addressType, xpub: record.xpub, - enabled: false + enabled: true ) - accounts[index] = record - throw error + guard let currentIndex = accounts.firstIndex(where: { $0.id == attempt.accountId && $0.setupState != .active }) else { + try? await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: false + ) + throw WatchOnlyAccountError.authorizationAccountMissing + } + accounts[currentIndex].setupState = .authorizing + accounts[currentIndex].isTrackingEnabled = true + do { + try persist() + } catch { + if let rollbackIndex = accounts.firstIndex(where: { $0.id == attempt.accountId }) { + accounts[rollbackIndex].setupState = failureSetupState + accounts[rollbackIndex].isTrackingEnabled = isTrackingEnabledOnFailure + } + try? await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: isTrackingEnabledOnFailure + ) + throw error + } } } - func cancelSetupAuthorization(id: UUID) async throws { - guard let index = accounts.firstIndex(where: { $0.id == id && $0.setupState != .active }) else { return } - let record = accounts[index] - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey + func finishSetupAuthorizationAttempt(_ attempt: WatchOnlyAccountAuthorizationAttempt) { + guard activeAuthorizationAttempt == attempt else { return } + activeAuthorizationAttempt = nil + preservesAuthorizingStateOnFailure = false + } + + func cancelSetupAuthorization(attempt: WatchOnlyAccountAuthorizationAttempt) async throws { + try requireActiveAuthorizationAttempt(attempt) + try await Task { @MainActor in + try await lifecycleCoordinator.withLock { + try requireActiveAuthorizationAttempt(attempt) + guard let index = accounts.firstIndex(where: { $0.id == attempt.accountId && $0.setupState != .active }) else { return } + let record = accounts[index] + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + try await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: isTrackingEnabledOnFailure + ) + accounts[index].setupState = failureSetupState + accounts[index].isTrackingEnabled = isTrackingEnabledOnFailure + do { + try persist() + } catch { + if let rollbackIndex = accounts.firstIndex(where: { $0.id == attempt.accountId }) { + accounts[rollbackIndex].setupState = record.setupState + accounts[rollbackIndex].isTrackingEnabled = record.isTrackingEnabled + } + try? await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: record.isTrackingEnabled + ) + throw error + } + } + }.value + } + + func markSetupActive(attempt: WatchOnlyAccountAuthorizationAttempt) async throws { + try requireActiveAuthorizationAttempt(attempt) + try await Task { @MainActor in + try await lifecycleCoordinator.withLock { + try requireActiveAuthorizationAttempt(attempt) + guard accounts.contains(where: { $0.id == attempt.accountId }) else { + throw WatchOnlyAccountError.authorizationAccountMissing + } + accounts = try WatchOnlyAccountStore.markSetupActive(id: attempt.accountId, defaults: defaults) + } + }.value + } + + private func requireActiveAuthorizationAttempt(_ attempt: WatchOnlyAccountAuthorizationAttempt) throws { + guard activeAuthorizationAttempt == attempt else { + throw WatchOnlyAccountError.authorizationInProgress } + } - accounts[index].setupState = .pendingDelivery - accounts[index].isTrackingEnabled = false - try persist() - try await node.setWatchOnlyAccountTracking( - accountIndex: record.accountIndex, - addressType: addressType, - xpub: record.xpub, - enabled: false - ) + private var failureSetupState: WatchOnlyAccountSetupState { + preservesAuthorizingStateOnFailure ? .authorizing : .pendingDelivery } - func markSetupActive(id: UUID) throws { - accounts = try WatchOnlyAccountStore.markSetupActive(id: id, defaults: defaults) + private var isTrackingEnabledOnFailure: Bool { + preservesAuthorizingStateOnFailure } - func rename(id: UUID, name: String) throws { + func rename(id: UUID, name: String) async throws { let normalizedName = try Self.normalizedName(name) - guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } - accounts[index].name = normalizedName - try persist() + try await lifecycleCoordinator.withLock { + guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } + accounts[index].name = normalizedName + try persist() + } } func setTrackingEnabled(id: UUID, enabled: Bool) async throws { - guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } - let record = accounts[index] - guard record.setupState == .active else { return } - guard record.isTrackingEnabled != enabled else { return } - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey + try await lifecycleCoordinator.withLock { + guard let index = accounts.firstIndex(where: { $0.id == id }) else { return } + let record = accounts[index] + guard record.setupState == .active else { return } + guard record.isTrackingEnabled != enabled else { return } + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + try await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: enabled + ) + guard let currentIndex = accounts.firstIndex(where: { $0.id == id && $0.setupState == .active }) else { + try? await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: !enabled + ) + return + } + let wasTrackingEnabled = accounts[currentIndex].isTrackingEnabled + accounts[currentIndex].isTrackingEnabled = enabled + do { + try persist() + } catch { + if let rollbackIndex = accounts.firstIndex(where: { $0.id == id }) { + accounts[rollbackIndex].isTrackingEnabled = wasTrackingEnabled + } + try? await node.setWatchOnlyAccountTracking( + accountIndex: record.accountIndex, + addressType: addressType, + xpub: record.xpub, + enabled: !enabled + ) + throw error + } } + } - try await node.setWatchOnlyAccountTracking( - accountIndex: record.accountIndex, - addressType: addressType, - xpub: record.xpub, - enabled: enabled - ) - accounts[index].isTrackingEnabled = enabled - try persist() + func restore( + _ records: [WatchOnlyAccountRecord]?, + allocationState: WatchOnlyAccountAllocationState? + ) async throws { + try await lifecycleCoordinator.withLock { + try WatchOnlyAccountStore.restore(records, allocationState: allocationState, defaults: defaults) + try reloadFromStore() + } } - func reload() throws { + func reload() async throws { + try await lifecycleCoordinator.withLock { + try reloadFromStore() + } + } + + func clear() async throws { + try await Task { @MainActor in + try await lifecycleCoordinator.withLock { + WatchOnlyAccountStore.clear(defaults: defaults) + accounts = [] + } + }.value + } + + #if !BITKIT_NOTIFICATION_EXTENSION + func reconcileTracking() async throws { + try await lifecycleCoordinator.withLock { + let snapshot = try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults) + try await node.reconcileWatchOnlyAccountTracking( + records: snapshot.accounts, + managedRecords: snapshot.managedAccounts + ) + try WatchOnlyAccountStore.finishReconciliation(walletIndex: node.currentWalletIndex, defaults: defaults) + } + } + #endif + + private func reloadFromStore() throws { accounts = try WatchOnlyAccountStore.load(defaults: defaults) } diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index 92b106701..617cd3e4f 100644 --- a/Bitkit/Utilities/AppReset.swift +++ b/Bitkit/Utilities/AppReset.swift @@ -46,7 +46,7 @@ enum AppReset { if let bundleID = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleID) } - try WatchOnlyAccountManager.shared.reload() + try await WatchOnlyAccountManager.shared.clear() // Singleton retains stale @AppStorage values after removePersistentDomain SettingsViewModel.shared.resetToDefaults() diff --git a/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift index becca0c60..f15daccbc 100644 --- a/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift +++ b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift @@ -75,14 +75,14 @@ struct WatchOnlyAccountsView: View { .navigationBarHidden(true) .task { do { - try manager.reload() + try await manager.reload() } catch { app.toast(type: .error, title: t("common__error"), description: error.localizedDescription) } } .sheet(item: $selectedAccount) { account in WatchOnlyAccountDetailsSheet(account: account) { name in - try manager.rename(id: account.id, name: name) + try await manager.rename(id: account.id, name: name) } .environmentObject(app) } @@ -149,11 +149,11 @@ private struct WatchOnlyAccountDetailsSheet: View { @EnvironmentObject private var app: AppViewModel let account: WatchOnlyAccountRecord - let onRename: (String) throws -> Void + let onRename: (String) async throws -> Void @State private var name: String - init(account: WatchOnlyAccountRecord, onRename: @escaping (String) throws -> Void) { + init(account: WatchOnlyAccountRecord, onRename: @escaping (String) async throws -> Void) { self.account = account self.onRename = onRename _name = State(initialValue: account.name) @@ -179,7 +179,9 @@ private struct WatchOnlyAccountDetailsSheet: View { testIdentifier: "WatchOnlyAccountName_\(account.accountIndex)", submitLabel: .done ) - .onSubmit(saveName) + .onSubmit { + Task { await saveName() } + } CaptionMText(t("watch_only_accounts__xpub"), textColor: .white64) .padding(.top, 24) @@ -194,7 +196,7 @@ private struct WatchOnlyAccountDetailsSheet: View { HStack(spacing: 12) { CustomButton(title: t("watch_only_accounts__save_name"), variant: .secondary) { - saveName() + Task { await saveName() } } .accessibilityIdentifier("WatchOnlyAccountSaveName_\(account.accountIndex)") @@ -213,9 +215,10 @@ private struct WatchOnlyAccountDetailsSheet: View { .presentationCornerRadius(32) } - private func saveName() { + @MainActor + private func saveName() async { do { - try onRename(name) + try await onRename(name) app.toast(type: .success, title: t("watch_only_accounts__name_saved")) dismiss() } catch { diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index f5594138d..72358099f 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -52,11 +52,13 @@ func approvePubkyAuthRequest( let accountManager = accountManager ?? .shared if request.bitkitClaim == .watchOnlyAccountV1 { let preparedClaim = try await accountManager.prepareUnsignedClaim(authUrl: authUrl, name: accountName) + let authorizationAttempt = try accountManager.acquireSetupAuthorizationAttempt(id: preparedClaim.0.id) + defer { accountManager.finishSetupAuthorizationAttempt(authorizationAttempt) } do { - try await accountManager.beginSetupAuthorization(id: preparedClaim.0.id) + try await accountManager.beginSetupAuthorization(attempt: authorizationAttempt) } catch { do { - try await accountManager.cancelSetupAuthorization(id: preparedClaim.0.id) + try await accountManager.cancelSetupAuthorization(attempt: authorizationAttempt) } catch let cleanupError { Logger.error("Failed to unload incomplete watch-only account: \(cleanupError)", context: "PubkyAuthApprovalSheet") } @@ -67,14 +69,14 @@ func approvePubkyAuthRequest( } catch { if !PubkyService.didDeliverCompanionClaim(error: error) { do { - try await accountManager.cancelSetupAuthorization(id: preparedClaim.0.id) + try await accountManager.cancelSetupAuthorization(attempt: authorizationAttempt) } catch let cleanupError { Logger.error("Failed to unload incomplete watch-only account: \(cleanupError)", context: "PubkyAuthApprovalSheet") } } throw error } - try accountManager.markSetupActive(id: preparedClaim.0.id) + try await accountManager.markSetupActive(attempt: authorizationAttempt) } else { try await ordinaryApproval(authUrl, request.capabilities, secretKeyHex) } diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index 487a06748..bf376c547 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -128,7 +128,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let firstConfirmation = Task { @MainActor in try await harness.confirm() } - await companionApprovalGate.waitUntilFirstApprovalStarts() + try await companionApprovalGate.waitUntilFirstApprovalStarts() do { try await harness.confirm() @@ -150,6 +150,67 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(node.trackingChanges, [true]) } + @MainActor + func testReplacingAndReopeningSheetCannotStartConcurrentCompanionApproval() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let firstAuthUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let secondAuthUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=f3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let firstRequest = try PubkyAuthRequest.parse(url: firstAuthUrl) + let secondRequest = try PubkyAuthRequest.parse(url: secondAuthUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + let companionApprovalGate = ApprovalCompanionGate() + let firstApproval = Task { @MainActor in + try await approvePubkyAuthRequest( + request: firstRequest, + authUrl: firstAuthUrl, + accountName: "First account", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in await companionApprovalGate.approve() } + ) + } + try await companionApprovalGate.waitUntilFirstApprovalStarts() + + for (request, authUrl) in [(secondRequest, secondAuthUrl), (firstRequest, firstAuthUrl)] { + do { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Replacement account", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in XCTFail("Concurrent companion approval must not start") } + ) + XCTFail("Expected concurrent authorization to be rejected") + } catch { + XCTAssertEqual(error as? Bitkit.WatchOnlyAccountError, .authorizationInProgress) + } + } + + let companionApprovalCount = await companionApprovalGate.approvalCount + XCTAssertEqual(companionApprovalCount, 1) + XCTAssertEqual(node.trackingChanges, [true]) + XCTAssertEqual(manager.accounts.map(\.setupState), [.authorizing, .pendingDelivery]) + + await companionApprovalGate.releaseFirstApproval() + try await firstApproval.value + try await approvePubkyAuthRequest( + request: secondRequest, + authUrl: secondAuthUrl, + accountName: "Second account", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in } + ) + + XCTAssertEqual(manager.accounts.map(\.setupState), [.active, .active]) + XCTAssertEqual(node.trackingChanges, [true, true]) + } + @MainActor func testNormalAuthorizationFailureAfterCompanionDeliveryKeepsAccountTracked() async throws { let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" @@ -179,6 +240,46 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(node.trackingChanges, [true]) } + @MainActor + func testRetryFailureAfterCompanionDeliveryKeepsAccountTracked() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + + await XCTAssertThrowsErrorAsync { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in + throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") + } + ) + } + + await XCTAssertThrowsErrorAsync { + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in throw ApprovalFakeError.deliveryFailed } + ) + } + + XCTAssertEqual(manager.accounts.first?.setupState, .authorizing) + XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, true) + XCTAssertEqual(node.trackingChanges, [true, true, true]) + } + @MainActor func testTrackingPreparationFailureUnloadsAccountBeforeApproval() async throws { let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" @@ -208,10 +309,51 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, false) XCTAssertEqual(node.trackingChanges, [true, false]) } + + @MainActor + func testCancellationDuringCompanionDeliveryStillUnloadsAccount() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + node.checkCancellationWhenDisabling = true + let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + let companionApprovalGate = ApprovalCompanionGate() + + let approval = Task { @MainActor in + try await approvePubkyAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: manager, + companionApproval: { _, _, _ in + await companionApprovalGate.approve() + try Task.checkCancellation() + } + ) + } + try await companionApprovalGate.waitUntilFirstApprovalStarts() + approval.cancel() + await companionApprovalGate.releaseFirstApproval() + + do { + try await approval.value + XCTFail("Expected companion approval cancellation") + } catch is CancellationError {} + + XCTAssertEqual(manager.accounts.first?.setupState, .pendingDelivery) + XCTAssertEqual(manager.accounts.first?.isTrackingEnabled, false) + XCTAssertEqual(node.trackingChanges, [true, false]) + } } private enum ApprovalFakeError: Error { case deliveryFailed + case timedOut case trackingPreparationFailed } @@ -260,7 +402,6 @@ private final class ApprovalSingleFlightHarness { private actor ApprovalCompanionGate { private var count = 0 private var firstApprovalContinuation: CheckedContinuation? - private var firstApprovalStartWaiters: [CheckedContinuation] = [] private var hasStartedFirstApproval = false var approvalCount: Int { @@ -272,20 +413,18 @@ private actor ApprovalCompanionGate { guard count == 1 else { return } hasStartedFirstApproval = true - let waiters = firstApprovalStartWaiters - firstApprovalStartWaiters.removeAll() - waiters.forEach { $0.resume() } await withCheckedContinuation { continuation in firstApprovalContinuation = continuation } } - func waitUntilFirstApprovalStarts() async { - guard !hasStartedFirstApproval else { return } - - await withCheckedContinuation { continuation in - firstApprovalStartWaiters.append(continuation) + func waitUntilFirstApprovalStarts(timeout: Duration = .seconds(2)) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !hasStartedFirstApproval { + guard clock.now < deadline else { throw ApprovalFakeError.timedOut } + await Task.yield() } } @@ -298,6 +437,7 @@ private actor ApprovalCompanionGate { private final class ApprovalFakeWatchOnlyAccountNode: Bitkit.WatchOnlyAccountNodeHandling { var currentWalletIndex = 0 var failNextTrackingPreparation = false + var checkCancellationWhenDisabling = false private(set) var trackingChanges: [Bool] = [] func exportWatchOnlyAccountXpub(accountIndex _: UInt32, addressType _: LDKNode.AddressType) async throws -> String { @@ -310,6 +450,9 @@ private final class ApprovalFakeWatchOnlyAccountNode: Bitkit.WatchOnlyAccountNod xpub _: String, enabled: Bool ) async throws { + if !enabled, checkCancellationWhenDisabling { + try Task.checkCancellation() + } trackingChanges.append(enabled) if enabled, failNextTrackingPreparation { failNextTrackingPreparation = false @@ -317,6 +460,11 @@ private final class ApprovalFakeWatchOnlyAccountNode: Bitkit.WatchOnlyAccountNod } } + func reconcileWatchOnlyAccountTracking( + records _: [Bitkit.WatchOnlyAccountRecord], + managedRecords _: [Bitkit.WatchOnlyAccountRecord] + ) async throws {} + private func base58CheckEncode(_ payload: Data) -> String { Base58.base58CheckEncode([UInt8](payload)) } diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 680437e82..7d7d70925 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -101,6 +101,682 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ), 2) } + func testRestorePreservesExactLocalPendingReservationForRetry() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: defaults + ), 1) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + + try WatchOnlyAccountStore.restore( + snapshot.accounts, + allocationState: snapshot.allocationState, + defaults: defaults + ) + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: defaults + ), 1) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "next", + defaults: defaults + ), 2) + } + + func testRestoreRejectsDivergentReservationForLocalPendingRequest() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + for index in 1 ... 6 { + let requestFingerprint = "completed-\(index)" + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: requestFingerprint, + defaults: defaults + ), UInt32(index)) + try WatchOnlyAccountStore.completeAllocation( + walletIndex: 0, + requestFingerprint: requestFingerprint, + defaults: defaults + ) + } + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: defaults + ), 7) + + try WatchOnlyAccountStore.restore( + [], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 7], + pendingAccountIndexByRequest: ["0:pending": 7] + ), + defaults: defaults + ) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: defaults + ), 7) + + try WatchOnlyAccountStore.restore( + [], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 0], + pendingAccountIndexByRequest: ["0:pending": 8] + ), + defaults: defaults + ) + + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:pending"]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending", + defaults: defaults + ), 9) + } + + func testRestoreBurnsConflictingPendingReservationSlot() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + try WatchOnlyAccountStore.restore( + [], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 0], + pendingAccountIndexByRequest: [ + "0:first": 1, + "0:second": 1, + ] + ), + defaults: defaults + ) + + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertTrue(snapshot.allocationState.pendingAccountIndexByRequest.isEmpty) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "next", + defaults: defaults + ), 2) + } + + func testRestoreSanitizesDuplicateAccountOwners() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let sharedId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let pendingDuplicateId = makeRecord( + accountIndex: 1, + xpub: xpub, + id: sharedId, + requestFingerprint: "pending-duplicate-id" + ) + let activeDuplicateId = makeRecord( + accountIndex: 2, + xpub: xpub, + setupState: .active, + id: sharedId, + requestFingerprint: "completed-request" + ) + let pendingDuplicateSlot = makeRecord( + accountIndex: 3, + xpub: xpub, + requestFingerprint: "pending-duplicate-slot" + ) + let authorizingDuplicateSlot = makeRecord( + accountIndex: 3, + xpub: xpub, + setupState: .authorizing, + requestFingerprint: "authorizing-duplicate-slot" + ) + let pendingDuplicateRequest = makeRecord( + accountIndex: 4, + xpub: xpub, + requestFingerprint: "duplicate-incomplete-request" + ) + let authorizingDuplicateRequest = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .authorizing, + requestFingerprint: "duplicate-incomplete-request" + ) + let repeatedCompletedRequest = makeRecord( + accountIndex: 6, + xpub: xpub, + setupState: .active, + requestFingerprint: "completed-request" + ) + + try WatchOnlyAccountStore.restore( + [ + pendingDuplicateId, + activeDuplicateId, + pendingDuplicateSlot, + authorizingDuplicateSlot, + pendingDuplicateRequest, + authorizingDuplicateRequest, + repeatedCompletedRequest, + ], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 6], + pendingAccountIndexByRequest: [ + "0:pending-duplicate-id": 1, + "0:duplicate-incomplete-request": 4, + ] + ), + defaults: defaults + ) + + let restored = try WatchOnlyAccountStore.load(defaults: defaults) + XCTAssertEqual( + Set(restored.map(\.id)), + Set([activeDuplicateId.id, authorizingDuplicateSlot.id, authorizingDuplicateRequest.id, repeatedCompletedRequest.id]) + ) + XCTAssertEqual(restored.map(\.accountIndex), [2, 3, 5, 6]) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:pending-duplicate-id"]) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:duplicate-incomplete-request"]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "pending-duplicate-id", + defaults: defaults + ), 7) + } + + func testRestoreRaisesHighWaterForDiscardedDuplicateAccount() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sharedId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let retained = makeRecord( + accountIndex: 1, + xpub: xpub, + setupState: .active, + id: sharedId, + requestFingerprint: "retained" + ) + let discarded = makeRecord( + accountIndex: 7, + xpub: xpub, + setupState: .active, + id: sharedId, + requestFingerprint: "discarded" + ) + + try WatchOnlyAccountStore.restore([discarded, retained], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [retained]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "next", + defaults: defaults + ), 8) + } + + func testRestoreRejectsReservationAtDiscardedAccountSlot() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sharedId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let retained = makeRecord(accountIndex: 1, xpub: xpub, setupState: .active, id: sharedId) + let discarded = makeRecord(accountIndex: 7, xpub: xpub, setupState: .active, id: sharedId) + + try WatchOnlyAccountStore.restore( + [discarded, retained], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 7], + pendingAccountIndexByRequest: ["0:retry": 7] + ), + defaults: defaults + ) + + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:retry"]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "retry", + defaults: defaults + ), 8) + } + + func testRestoreNormalizesTrackingForIncompleteAccounts() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let pending = makeRecord( + accountIndex: 1, + xpub: xpub, + isTrackingEnabled: true, + requestFingerprint: "pending" + ) + let authorizing = makeRecord( + accountIndex: 2, + xpub: xpub, + isTrackingEnabled: false, + setupState: .authorizing, + requestFingerprint: "authorizing" + ) + let disabledActive = makeRecord( + accountIndex: 3, + xpub: xpub, + isTrackingEnabled: false, + setupState: .active, + requestFingerprint: "active" + ) + + try WatchOnlyAccountStore.restore([pending, authorizing, disabledActive], defaults: defaults) + + let accounts = try WatchOnlyAccountStore.load(defaults: defaults) + XCTAssertEqual(accounts.first(where: { $0.id == pending.id })?.isTrackingEnabled, false) + XCTAssertEqual(accounts.first(where: { $0.id == authorizing.id })?.isTrackingEnabled, true) + XCTAssertEqual(accounts.first(where: { $0.id == disabledActive.id })?.isTrackingEnabled, false) + } + + func testRestoreDropsUnusableAccountsAndBurnsTheirIndexes() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let valid = makeRecord(accountIndex: 1, xpub: xpub, setupState: .active) + let invalidAddressType = makeRecord( + accountIndex: 7, + xpub: xpub, + addressType: "legacy", + setupState: .active + ) + let invalidXpub = makeRecord(accountIndex: 8, xpub: "not-an-xpub", setupState: .authorizing) + let accountZero = makeRecord(accountIndex: 0, xpub: xpub, setupState: .active) + let outOfRange = makeRecord(accountIndex: .max, xpub: xpub, setupState: .active) + + try WatchOnlyAccountStore.restore( + [outOfRange, invalidXpub, accountZero, invalidAddressType, valid], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": .max], + pendingAccountIndexByRequest: ["0:out-of-range": .max] + ), + defaults: defaults + ) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [valid]) + XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults), [valid]) + XCTAssertNil( + try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + .allocationState.pendingAccountIndexByRequest["0:out-of-range"] + ) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "next", + defaults: defaults + ), 9) + } + + func testRestorePreservesAuthorizingAccountsOverBackupConflicts() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let authorizingById = makeRecord( + accountIndex: 1, + xpub: xpub, + setupState: .authorizing, + requestFingerprint: "authorizing-by-id" + ) + let authorizingByKey = makeRecord( + accountIndex: 2, + xpub: xpub, + setupState: .authorizing, + requestFingerprint: "authorizing-by-key" + ) + try WatchOnlyAccountStore.save([authorizingById, authorizingByKey], defaults: defaults) + + let conflictingId = makeRecord( + accountIndex: 9, + xpub: xpub, + setupState: .active, + id: authorizingById.id, + requestFingerprint: "backup-id-conflict" + ) + let conflictingKey = makeRecord( + accountIndex: authorizingByKey.accountIndex, + xpub: xpub, + setupState: .active, + requestFingerprint: "backup-key-conflict" + ) + let restored = makeRecord( + accountIndex: 7, + xpub: xpub, + setupState: .active, + requestFingerprint: "restored" + ) + try WatchOnlyAccountStore.restore( + [conflictingId, conflictingKey, restored], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 9], + pendingAccountIndexByRequest: ["0:restored": restored.accountIndex] + ), + defaults: defaults + ) + + let accounts = try WatchOnlyAccountStore.load(defaults: defaults) + XCTAssertEqual(Set(accounts.map(\.id)), Set([authorizingById.id, authorizingByKey.id, restored.id])) + XCTAssertEqual(accounts.first(where: { $0.id == authorizingById.id }), authorizingById) + XCTAssertEqual(accounts.first(where: { $0.id == authorizingByKey.id }), authorizingByKey) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:authorizing-by-id"], 1) + XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:authorizing-by-key"], 2) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:restored"]) + + _ = try WatchOnlyAccountStore.markSetupActive(id: authorizingById.id, defaults: defaults) + XCTAssertEqual( + try WatchOnlyAccountStore.load(defaults: defaults).first(where: { $0.id == authorizingById.id })?.setupState, + .active + ) + } + + func testRestorePreservesLocalOwnerWhenBackupReusesItsSlot() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let local = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "local-owner" + ) + let conflictingBackup = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "restored-owner" + ) + try WatchOnlyAccountStore.save([local], defaults: defaults) + + try WatchOnlyAccountStore.restore([conflictingBackup], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) + XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [local]) + } + + func testRestorePrefersDeliveredOwnerOverLocalPendingSlot() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let localPending = makeRecord( + accountIndex: 5, + xpub: xpub, + isTrackingEnabled: false, + requestFingerprint: "local-pending" + ) + let restoredActive = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "restored-active" + ) + try WatchOnlyAccountStore.save([localPending], defaults: defaults) + + try WatchOnlyAccountStore.restore([restoredActive], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [restoredActive]) + XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults), [restoredActive]) + XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [restoredActive]) + } + + func testRestoreKeepsTrackingEnabledAcrossDeliveredOwnerConflict() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let localDisabled = makeRecord( + accountIndex: 5, + xpub: xpub, + isTrackingEnabled: false, + setupState: .active, + requestFingerprint: "local-active" + ) + let restoredEnabled = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "restored-active" + ) + try WatchOnlyAccountStore.save([localDisabled], defaults: defaults) + + try WatchOnlyAccountStore.restore([restoredEnabled], defaults: defaults) + + let account = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) + XCTAssertEqual(account.id, localDisabled.id) + XCTAssertTrue(account.isTrackingEnabled) + XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults), [account]) + } + + func testRestoreProtectsOwnerAwaitingUnloadFromConflictingSlot() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let local = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "local-owner" + ) + let conflictingBackup = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "restored-owner" + ) + try WatchOnlyAccountStore.save([local], defaults: defaults) + try WatchOnlyAccountStore.restore([], defaults: defaults) + + try WatchOnlyAccountStore.restore([conflictingBackup], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) + XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [local]) + } + + func testRestoreDoesNotRegressActiveLocalOwnerToIncomplete() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let local = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .active, + requestFingerprint: "same-owner" + ) + let incompleteBackup = makeRecord( + accountIndex: 5, + xpub: xpub, + isTrackingEnabled: false, + requestFingerprint: local.requestFingerprint + ) + try WatchOnlyAccountStore.save([local], defaults: defaults) + + try WatchOnlyAccountStore.restore([incompleteBackup], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) + } + + func testRestoreUsesSameCanonicalIncompleteOwnerRegardlessOfInputOrder() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let lowerIndexId = try XCTUnwrap(UUID(uuidString: "ffffffff-ffff-ffff-ffff-ffffffffffff")) + let higherIndexId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let lowerIndex = makeRecord( + accountIndex: 3, + xpub: xpub, + isTrackingEnabled: false, + id: lowerIndexId, + requestFingerprint: "shared-request" + ) + let higherIndex = makeRecord( + accountIndex: 4, + xpub: xpub, + isTrackingEnabled: false, + id: higherIndexId, + requestFingerprint: lowerIndex.requestFingerprint + ) + + try WatchOnlyAccountStore.restore([higherIndex, lowerIndex], defaults: defaults) + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [lowerIndex]) + + try WatchOnlyAccountStore.restore([lowerIndex, higherIndex], defaults: defaults) + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [lowerIndex]) + } + + func testRestoreDropsPendingReservationThatCollidesWithAuthorizingRequest() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let authorizing = makeRecord( + accountIndex: 5, + xpub: xpub, + setupState: .authorizing, + requestFingerprint: "request-a" + ) + try WatchOnlyAccountStore.restore( + [authorizing], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 5], + pendingAccountIndexByRequest: ["0:request-a": 5] + ), + defaults: defaults + ) + + let conflictingBackupAccount = makeRecord( + accountIndex: 5, + xpub: xpub, + requestFingerprint: "request-b" + ) + try WatchOnlyAccountStore.restore( + [conflictingBackupAccount], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 5], + pendingAccountIndexByRequest: ["0:request-b": 5] + ), + defaults: defaults + ) + + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertEqual(snapshot.accounts, [authorizing]) + XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:request-a"], 5) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:request-b"]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "request-b", + defaults: defaults + ), 6) + } + + func testRestoreDropsUnboundPendingReservationBelowLocalHighWaterMark() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "first", + defaults: defaults + ), 1) + try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "first", defaults: defaults) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "second", + defaults: defaults + ), 2) + try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "second", defaults: defaults) + + try WatchOnlyAccountStore.restore( + [], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 1], + pendingAccountIndexByRequest: ["0:restored-request": 1] + ), + defaults: defaults + ) + + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:restored-request"]) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "restored-request", + defaults: defaults + ), 3) + } + + func testRestoreDropsPendingReservationForAccountAwaitingUnload() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let accountAwaitingUnload = makeRecord( + accountIndex: 5, + xpub: base58CheckEncode(Data(repeating: 1, count: 78)), + setupState: .active, + requestFingerprint: "current-request" + ) + try WatchOnlyAccountStore.save([accountAwaitingUnload], defaults: defaults) + + try WatchOnlyAccountStore.restore( + [], + allocationState: WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: ["0": 5], + pendingAccountIndexByRequest: ["0:restored-request": 5] + ), + defaults: defaults + ) + + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:restored-request"]) + XCTAssertEqual( + try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, + [accountAwaitingUnload] + ) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "restored-request", + defaults: defaults + ), 6) + } + func testCorruptedStateFailsClosedWithoutResettingAllocator() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -145,6 +821,42 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ), 6) } + func testLegacyLoadDoesNotWriteDuringRead() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let legacyRecord = makeRecord( + accountIndex: 3, + xpub: base58CheckEncode(Data(repeating: 1, count: 78)), + setupState: .active + ) + try defaults.set(JSONEncoder().encode([legacyRecord]), forKey: "watchOnlyAccountsV1") + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [legacyRecord]) + XCTAssertNil(defaults.data(forKey: WatchOnlyAccountStore.dataKey)) + + try WatchOnlyAccountStore.save([legacyRecord], defaults: defaults) + XCTAssertNotNil(defaults.data(forKey: WatchOnlyAccountStore.dataKey)) + } + + func testReconciliationClearsPendingUnloadsOnlyForCurrentWallet() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let walletZero = makeRecord(accountIndex: 1, xpub: xpub, walletIndex: 0, setupState: .active) + let walletOne = makeRecord(accountIndex: 1, xpub: xpub, walletIndex: 1, setupState: .active) + try WatchOnlyAccountStore.save([walletZero, walletOne], defaults: defaults) + try WatchOnlyAccountStore.restore([], defaults: defaults) + + try WatchOnlyAccountStore.finishReconciliation(walletIndex: 0, defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [walletOne]) + + try WatchOnlyAccountStore.finishReconciliation(walletIndex: 1, defaults: defaults) + XCTAssertTrue(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts.isEmpty) + } + func testStartupTrackingIncludesEnabledActiveAndAuthorizingAccountsForCurrentWallet() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) @@ -176,7 +888,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let first = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Store one") let retry = try await manager.prepareUnsignedClaim(authUrl: "pubkyauth:///?secret=one", name: "Changed") - try manager.markSetupActive(id: first.0.id) + try await activateSetupAuthorization(manager: manager, id: first.0.id) let repeatedAfterCompletion = try await manager.prepareUnsignedClaim( authUrl: "pubkyauth:///?secret=one", name: "Store one again" @@ -302,10 +1014,9 @@ final class WatchOnlyAccountServiceTests: XCTestCase { name: "Original" ) - try manager.rename(id: prepared.0.id, name: "Creator shop") + try await manager.rename(id: prepared.0.id, name: "Creator shop") XCTAssertFalse(try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first).isTrackingEnabled) - try await manager.beginSetupAuthorization(id: prepared.0.id) - try manager.markSetupActive(id: prepared.0.id) + try await activateSetupAuthorization(manager: manager, id: prepared.0.id) try await manager.setTrackingEnabled(id: prepared.0.id, enabled: false) let stored = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) @@ -327,20 +1038,385 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ) } + @MainActor + func testAuthorizationAndActivationFailWhenPreparedAccountIsMissing() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = WatchOnlyAccountManager(defaults: defaults, node: FakeWatchOnlyAccountNode()) + let missingId = UUID() + + for operation in [ + { (attempt: WatchOnlyAccountAuthorizationAttempt) in try await manager.beginSetupAuthorization(attempt: attempt) }, + { (attempt: WatchOnlyAccountAuthorizationAttempt) in try await manager.markSetupActive(attempt: attempt) }, + ] { + let attempt = try manager.acquireSetupAuthorizationAttempt(id: missingId) + do { + try await operation(attempt) + XCTFail("Expected a missing authorization account error") + } catch { + XCTAssertEqual(error as? WatchOnlyAccountError, .authorizationAccountMissing) + } + manager.finishSetupAuthorizationAttempt(attempt) + } + } + + @MainActor + func testFailedAuthorizationUnloadKeepsPersistedAuthorizingState() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=failed-unload", + name: "Failed unload" + ) + let authorizationAttempt = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + defer { manager.finishSetupAuthorizationAttempt(authorizationAttempt) } + try await manager.beginSetupAuthorization(attempt: authorizationAttempt) + node.failNextTrackingDisable = true + + await XCTAssertThrowsErrorAsync { + try await manager.cancelSetupAuthorization(attempt: authorizationAttempt) + } + + let stored = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) + XCTAssertEqual(stored.setupState, .authorizing) + XCTAssertTrue(stored.isTrackingEnabled) + XCTAssertEqual(node.trackedAccountIndexes, [prepared.0.accountIndex]) + } + + @MainActor + func testAuthorizationAttemptRemainsExclusiveAfterCleanupUntilOwnerFinishes() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = WatchOnlyAccountManager(defaults: defaults, node: FakeWatchOnlyAccountNode()) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=exclusive-cleanup", + name: "Exclusive cleanup" + ) + let firstAttempt = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + try await manager.beginSetupAuthorization(attempt: firstAttempt) + try await manager.cancelSetupAuthorization(attempt: firstAttempt) + + do { + _ = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + XCTFail("Expected the original authorization owner to remain exclusive") + } catch { + XCTAssertEqual(error as? WatchOnlyAccountError, .authorizationInProgress) + } + + manager.finishSetupAuthorizationAttempt(firstAttempt) + let retryAttempt = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + defer { manager.finishSetupAuthorizationAttempt(retryAttempt) } + try await manager.beginSetupAuthorization(attempt: retryAttempt) + manager.finishSetupAuthorizationAttempt(firstAttempt) + + do { + _ = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + XCTFail("Expected a stale finish not to release the retry") + } catch { + XCTAssertEqual(error as? WatchOnlyAccountError, .authorizationInProgress) + } + do { + try await manager.cancelSetupAuthorization(attempt: firstAttempt) + XCTFail("Expected stale cleanup to be rejected") + } catch { + XCTAssertEqual(error as? WatchOnlyAccountError, .authorizationInProgress) + } + + let stored = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) + XCTAssertEqual(stored.setupState, .authorizing) + XCTAssertTrue(stored.isTrackingEnabled) + } + + @MainActor + func testActivationPersistsAfterCallerCancellationWhileWaitingForLifecycleLock() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let contentionProbe = LifecycleQueueProbe() + let coordinator = WatchOnlyAccountLifecycleCoordinator { + contentionProbe.markQueued() + } + let lockGate = LifecycleTestGate() + let lockProbe = LifecycleLockProbe() + let manager = WatchOnlyAccountManager( + defaults: defaults, + lifecycleCoordinator: coordinator, + node: FakeWatchOnlyAccountNode() + ) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=cancelled-activation", + name: "Cancelled activation" + ) + let authorizationAttempt = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + defer { manager.finishSetupAuthorizationAttempt(authorizationAttempt) } + try await manager.beginSetupAuthorization(attempt: authorizationAttempt) + let lockHolder = Task { + try await coordinator.withLock { + await lockProbe.markEntered() + await lockGate.wait() + } + } + try await waitUntil { await lockProbe.hasEntered } + + let activation = Task { @MainActor in + try await manager.markSetupActive(attempt: authorizationAttempt) + } + try await waitUntil { contentionProbe.queuedCount >= 1 } + activation.cancel() + await lockGate.open() + try await lockHolder.value + try await activation.value + + let stored = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) + XCTAssertEqual(stored.setupState, .active) + XCTAssertTrue(stored.isTrackingEnabled) + } + + @MainActor + func testAuthorizationSerializesReconciliationUntilTrackingStateIsPersisted() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let contentionProbe = LifecycleQueueProbe() + let coordinator = WatchOnlyAccountLifecycleCoordinator { + contentionProbe.markQueued() + } + let node = FakeWatchOnlyAccountNode() + let trackingGate = LifecycleTestGate() + node.trackingGate = trackingGate + let manager = WatchOnlyAccountManager(defaults: defaults, lifecycleCoordinator: coordinator, node: node) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=serialized", + name: "Serialized" + ) + + let authorizationAttempt = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + defer { manager.finishSetupAuthorizationAttempt(authorizationAttempt) } + let authorization = Task { @MainActor in + try await manager.beginSetupAuthorization(attempt: authorizationAttempt) + } + try await waitUntil { !node.trackingChanges.isEmpty } + let reconciliation = Task { @MainActor in + try await manager.reconcileTracking() + } + + try await waitUntil { contentionProbe.queuedCount >= 1 } + XCTAssertTrue(node.reconciliationSnapshots.isEmpty) + XCTAssertEqual(node.trackedAccountIndexes, [prepared.0.accountIndex]) + + await trackingGate.open() + try await authorization.value + try await reconciliation.value + + XCTAssertEqual(node.reconciliationSnapshots.count, 1) + XCTAssertEqual(node.reconciliationSnapshots.first?.first?.setupState, .authorizing) + XCTAssertEqual(node.reconciliationSnapshots.first?.first?.isTrackingEnabled, true) + XCTAssertEqual(node.trackedAccountIndexes, [prepared.0.accountIndex]) + let stored = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) + XCTAssertEqual(stored.setupState, .authorizing) + XCTAssertTrue(stored.isTrackingEnabled) + } + + @MainActor + func testCancelledLifecycleWaiterDoesNotEnterCriticalSection() async throws { + let contentionProbe = LifecycleQueueProbe() + let coordinator = WatchOnlyAccountLifecycleCoordinator { + contentionProbe.markQueued() + } + let holderGate = LifecycleTestGate() + let holderProbe = LifecycleLockProbe() + let waiterProbe = LifecycleLockProbe() + let followingWaiterProbe = LifecycleLockProbe() + + let holder = Task { + try await coordinator.withLock { + await holderProbe.markEntered() + await holderGate.wait() + } + } + try await waitUntil { await holderProbe.hasEntered } + + let waiter = Task { + try await coordinator.withLock { + await waiterProbe.markEntered() + } + } + try await waitUntil { contentionProbe.queuedCount >= 1 } + waiter.cancel() + + let followingWaiter = Task { + try await coordinator.withLock { + await followingWaiterProbe.markEntered() + } + } + try await waitUntil { contentionProbe.queuedCount >= 2 } + + await holderGate.open() + try await holder.value + do { + try await waiter.value + XCTFail("Expected the cancelled lifecycle waiter to throw") + } catch is CancellationError {} + try await followingWaiter.value + + let cancelledWaiterDidEnter = await waiterProbe.hasEntered + let followingWaiterDidEnter = await followingWaiterProbe.hasEntered + XCTAssertFalse(cancelledWaiterDidEnter) + XCTAssertTrue(followingWaiterDidEnter) + } + + @MainActor + func testRestorePreservesInFlightAuthorization() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let contentionProbe = LifecycleQueueProbe() + let coordinator = WatchOnlyAccountLifecycleCoordinator { + contentionProbe.markQueued() + } + let node = FakeWatchOnlyAccountNode() + let trackingGate = LifecycleTestGate() + node.trackingGate = trackingGate + let manager = WatchOnlyAccountManager(defaults: defaults, lifecycleCoordinator: coordinator, node: node) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=restore-race", + name: "Original" + ) + let restored = makeRecord( + accountIndex: 8, + xpub: base58CheckEncode(Data(repeating: 8, count: 78)), + setupState: .active + ) + + let authorizationAttempt = try manager.acquireSetupAuthorizationAttempt(id: prepared.0.id) + defer { manager.finishSetupAuthorizationAttempt(authorizationAttempt) } + let authorization = Task { @MainActor in + try await manager.beginSetupAuthorization(attempt: authorizationAttempt) + } + try await waitUntil { !node.trackingChanges.isEmpty } + let restore = Task { @MainActor in + try await manager.restore([restored], allocationState: nil) + } + + try await waitUntil { contentionProbe.queuedCount >= 1 } + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults).first?.id, prepared.0.id) + + await trackingGate.open() + try await authorization.value + try await restore.value + + let accounts = try WatchOnlyAccountStore.load(defaults: defaults) + XCTAssertEqual(Set(accounts.map(\.id)), Set([prepared.0.id, restored.id])) + let authorizingAccount = try XCTUnwrap(accounts.first(where: { $0.id == prepared.0.id })) + XCTAssertEqual(authorizingAccount.setupState, .authorizing) + XCTAssertTrue(authorizingAccount.isTrackingEnabled) + XCTAssertEqual(manager.accounts, accounts) + + try await manager.markSetupActive(attempt: authorizationAttempt) + XCTAssertEqual(manager.accounts.first(where: { $0.id == prepared.0.id })?.setupState, .active) + } + + @MainActor + func testRestoreUnloadsAccountsMissingFromBackup() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=removed-by-restore", + name: "Original" + ) + try await activateSetupAuthorization(manager: manager, id: prepared.0.id) + let restored = makeRecord( + accountIndex: 8, + xpub: base58CheckEncode(Data(repeating: 8, count: 78)), + setupState: .active + ) + + try await manager.restore([restored], allocationState: nil) + try await manager.reconcileTracking() + + XCTAssertEqual(node.trackedAccountIndexes, [restored.accountIndex]) + XCTAssertEqual(node.reconciliationManagedSnapshots.count, 1) + XCTAssertEqual(Set(node.reconciliationManagedSnapshots[0].map(\.accountIndex)), [prepared.0.accountIndex, restored.accountIndex]) + XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [restored]) + } + + @MainActor + func testFailedRestoreReconciliationRetainsRemovedAccountsForRetry() async throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let node = FakeWatchOnlyAccountNode() + let manager = WatchOnlyAccountManager(defaults: defaults, node: node) + let prepared = try await manager.prepareUnsignedClaim( + authUrl: "pubkyauth:///?secret=failed-restore-reconciliation", + name: "Original" + ) + try await activateSetupAuthorization(manager: manager, id: prepared.0.id) + let restored = makeRecord( + accountIndex: 8, + xpub: base58CheckEncode(Data(repeating: 8, count: 78)), + setupState: .active + ) + try await manager.restore([restored], allocationState: nil) + node.failNextReconciliationAfterRemovals = true + + await XCTAssertThrowsErrorAsync { + try await manager.reconcileTracking() + } + + XCTAssertTrue(node.trackedAccountIndexes.isEmpty) + XCTAssertEqual( + try Set(WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts.map(\.accountIndex)), + [prepared.0.accountIndex, restored.accountIndex] + ) + + try await manager.reconcileTracking() + + XCTAssertEqual(node.trackedAccountIndexes, [restored.accountIndex]) + XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [restored]) + } + + @MainActor + private func activateSetupAuthorization(manager: WatchOnlyAccountManager, id: UUID) async throws { + let attempt = try manager.acquireSetupAuthorizationAttempt(id: id) + defer { manager.finishSetupAuthorizationAttempt(attempt) } + try await manager.beginSetupAuthorization(attempt: attempt) + try await manager.markSetupActive(attempt: attempt) + } + private func makeRecord( accountIndex: UInt32, xpub: String, walletIndex: Int = 0, + addressType: String = LDKNode.AddressType.nativeSegwit.stringValue, isTrackingEnabled: Bool = true, - setupState: WatchOnlyAccountSetupState = .pendingDelivery + setupState: WatchOnlyAccountSetupState = .pendingDelivery, + id: UUID = UUID(), + requestFingerprint: String = "request" ) -> WatchOnlyAccountRecord { WatchOnlyAccountRecord( - id: UUID(), + id: id, walletIndex: walletIndex, accountIndex: accountIndex, - addressType: LDKNode.AddressType.nativeSegwit.stringValue, + addressType: addressType, xpub: xpub, - requestFingerprint: "request", + requestFingerprint: requestFingerprint, createdAt: 1000, name: "Test", isTrackingEnabled: isTrackingEnabled, @@ -360,14 +1436,22 @@ private struct TrackingChange: Equatable { private enum FakeNodeError: Error { case creationFailed + case reconciliationFailed + case trackingFailed } private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { var currentWalletIndex = 0 var failNextCreation = false + var failNextReconciliationAfterRemovals = false + var failNextTrackingDisable = false var creationDelayNanoseconds: UInt64 = 0 + var trackingGate: LifecycleTestGate? private(set) var exportedAccountIndexes: [UInt32] = [] private(set) var trackingChanges: [TrackingChange] = [] + private(set) var reconciliationSnapshots: [[WatchOnlyAccountRecord]] = [] + private(set) var reconciliationManagedSnapshots: [[WatchOnlyAccountRecord]] = [] + private(set) var trackedAccountIndexes: Set = [] func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType _: LDKNode.AddressType) async throws -> String { exportedAccountIndexes.append(accountIndex) @@ -389,6 +1473,37 @@ private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { enabled: Bool ) async throws { trackingChanges.append(TrackingChange(accountIndex: accountIndex, enabled: enabled)) + if !enabled, failNextTrackingDisable { + failNextTrackingDisable = false + throw FakeNodeError.trackingFailed + } + if enabled { + trackedAccountIndexes.insert(accountIndex) + } else { + trackedAccountIndexes.remove(accountIndex) + } + if let trackingGate { + await trackingGate.wait() + } + } + + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws { + reconciliationSnapshots.append(records) + reconciliationManagedSnapshots.append(managedRecords) + let walletRecords = records.filter { $0.walletIndex == currentWalletIndex } + let managedAccountIndexes = Set(managedRecords.filter { $0.walletIndex == currentWalletIndex }.map(\.accountIndex)) + let desiredAccountIndexes = Set(walletRecords.filter { + ($0.setupState == .active || $0.setupState == .authorizing) && $0.isTrackingEnabled + }.map(\.accountIndex)) + trackedAccountIndexes.subtract(managedAccountIndexes.subtracting(desiredAccountIndexes)) + if failNextReconciliationAfterRemovals { + failNextReconciliationAfterRemovals = false + throw FakeNodeError.reconciliationFailed + } + trackedAccountIndexes.formUnion(desiredAccountIndexes) } private func base58CheckEncode(_ payload: Data) -> String { @@ -396,6 +1511,67 @@ private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { } } +private final class LifecycleQueueProbe: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var queuedCount: Int { + lock.withLock { count } + } + + func markQueued() { + lock.withLock { count += 1 } + } +} + +private actor LifecycleLockProbe { + private(set) var hasAttempted = false + private(set) var hasEntered = false + + func markAttempted() { + hasAttempted = true + } + + func markEntered() { + hasEntered = true + } +} + +private actor LifecycleTestGate { + private var isOpen = false + private var waiter: CheckedContinuation? + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + waiter = continuation + } + } + + func open() { + isOpen = true + waiter?.resume() + waiter = nil + } +} + +private enum LifecycleTestError: Error { + case timedOut +} + +@MainActor +private func waitUntil( + timeout: Duration = .seconds(2), + condition: @MainActor () async -> Bool +) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await !condition() { + guard clock.now < deadline else { throw LifecycleTestError.timedOut } + await Task.yield() + } +} + private func XCTAssertThrowsErrorAsync( _ expression: () async throws -> some Any, file: StaticString = #filePath, diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md index 03a7508e8..f857a101a 100644 --- a/Docs/watch-only-account-claim-v1.md +++ b/Docs/watch-only-account-claim-v1.md @@ -27,10 +27,12 @@ The signature input is the byte concatenation: ```text UTF8("x-bitkit-claim|watch-only-account-v1|") -|| SHA256(UTF8(decoded_auth_request_secret)) +|| SHA256(decoded_auth_request_secret) || claim_bytes[0..<84] ``` +`decoded_auth_request_secret` is the raw 32-byte value produced by base64url-no-pad decoding the URL's `secret` parameter, not UTF-8 text. + The server verifies the signature with the creator's Pubky Ed25519 public key from the authenticated session. Binding the signature to the request secret prevents a valid signed claim from being moved to a different request; possession of the relay secret alone is insufficient to substitute an attacker's xpub. ## Delivery and lifecycle @@ -44,6 +46,6 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. Retrying the same request can then finish normal authorization without losing visibility into addresses the server may already have derived. - Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. - Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. -- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. A v1 Paykit Server must not issue an index above `999`; supporting a higher index requires a future protocol signal that communicates the server's address high-water mark. -- Startup reconciliation restores runtime tracking and the pre-revealed range. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. +- Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. +- Startup reconciliation restores runtime tracking and the pre-revealed range. Reconciliation is serialized with setup, restore, and tracking mutations so a stale snapshot cannot undo the latest account state. When a backup replaces the account list, references to removed accounts remain locally until reconciliation successfully unloads them. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. - Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. From 4a79bc57e22995d41c1a5a62c948aa34d01586b2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 19:17:45 +0200 Subject: [PATCH 04/16] chore: rename changelog fragment --- .../next/{watch-only-account-claim.added.md => 630.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{watch-only-account-claim.added.md => 630.added.md} (100%) diff --git a/changelog.d/next/watch-only-account-claim.added.md b/changelog.d/next/630.added.md similarity index 100% rename from changelog.d/next/watch-only-account-claim.added.md rename to changelog.d/next/630.added.md From 7d73525ba54bf6600ac53395f4fde6e13ef28f50 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 19:37:34 +0200 Subject: [PATCH 05/16] ci: preserve inherited Swift conditions --- .github/workflows/ai-device-tests.yml | 2 +- .github/workflows/e2e-tests.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index c53905a38..71349c04a 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -99,7 +99,7 @@ jobs: -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$SIMULATOR_OS" \ -derivedDataPath DerivedData \ -resultBundlePath TestResults/TrezorBridgeDashboardUITests.xcresult \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD TEST_TREZOR_EMU' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) DEBUG E2E_BUILD TEST_TREZOR_EMU' \ -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ -parallel-testing-enabled NO \ | xcbeautify diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 578c8621e..baeea279d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -113,7 +113,7 @@ jobs: -configuration Debug \ -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$OS_VERSION" \ -derivedDataPath DerivedData \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) DEBUG E2E_BUILD' \ -allowProvisioningUpdates \ build @@ -202,7 +202,7 @@ jobs: -configuration Debug \ -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$OS_VERSION" \ -derivedDataPath DerivedData \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) DEBUG E2E_BUILD' \ -allowProvisioningUpdates \ build From 5ed0c8ac64a6e0da5c46dfb2a10e3bfeb2331983 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 15 Jul 2026 21:45:23 +0200 Subject: [PATCH 06/16] docs: clarify companion delivery errors --- Bitkit/Services/PubkyService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index d4c3f720c..15539cb10 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -110,6 +110,7 @@ enum PubkyService { static func didDeliverCompanionClaim(error: Error) -> Bool { guard let approvalError = error as? Paykit.PubkyAuthCompanionClaimApprovalError else { return false } + // Paykit uses AuthorizationFailure exclusively after companion delivery; every other variant is pre-delivery. if case .AuthorizationFailure = approvalError { return true } From eed7f02a72a36c323818e709cb2d4e8f96541470 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 09:34:39 +0200 Subject: [PATCH 07/16] refactor: scope watch-only accounts to the main app target --- .github/workflows/ai-device-tests.yml | 2 +- .github/workflows/e2e-tests.yml | 4 +- Bitkit.xcodeproj/project.pbxproj | 4 +- Bitkit/Services/LightningService.swift | 128 ++++++++---------- Bitkit/Services/WatchOnlyAccountService.swift | 30 ++-- 5 files changed, 78 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index 71349c04a..c53905a38 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -99,7 +99,7 @@ jobs: -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$SIMULATOR_OS" \ -derivedDataPath DerivedData \ -resultBundlePath TestResults/TrezorBridgeDashboardUITests.xcresult \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) DEBUG E2E_BUILD TEST_TREZOR_EMU' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD TEST_TREZOR_EMU' \ -only-testing:BitkitUITests/TrezorBridgeDashboardUITests \ -parallel-testing-enabled NO \ | xcbeautify diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index baeea279d..578c8621e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -113,7 +113,7 @@ jobs: -configuration Debug \ -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$OS_VERSION" \ -derivedDataPath DerivedData \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) DEBUG E2E_BUILD' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ -allowProvisioningUpdates \ build @@ -202,7 +202,7 @@ jobs: -configuration Debug \ -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=$OS_VERSION" \ -derivedDataPath DerivedData \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) DEBUG E2E_BUILD' \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ -allowProvisioningUpdates \ build diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index eafbf77b0..465fa6049 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -204,12 +204,14 @@ Services/CoreService.swift, Services/GeoService.swift, Services/LightningService.swift, + Services/WatchOnlyAccountService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, Utilities/LightningAmountConversion.swift, + Utilities/LocalizeHelpers.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -771,7 +773,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) BITKIT_NOTIFICATION_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -804,7 +805,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) BITKIT_NOTIFICATION_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 13c88d518..fe3ad6e51 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -87,9 +87,7 @@ class LightningService { let (selectedAddressType, monitoredTypes) = Self.addressTypeStateFromUserDefaults() config.addressType = selectedAddressType config.addressTypesToMonitor = monitoredTypes.filter { $0 != selectedAddressType } - #if !BITKIT_NOTIFICATION_EXTENSION - config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) - #endif + config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) @@ -264,13 +262,11 @@ class LightningService { try node.start() } - #if !BITKIT_NOTIFICATION_EXTENSION - do { - try await reconcileWatchOnlyAccounts() - } catch { - Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") - } - #endif + do { + try await reconcileWatchOnlyAccounts() + } catch { + Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") + } await refreshChannelCache() await refreshCache() @@ -473,9 +469,7 @@ class LightningService { throw AppError(serviceError: .nodeNotSetup) } - #if !BITKIT_NOTIFICATION_EXTENSION - try await reconcileWatchOnlyAccounts() - #endif + try await reconcileWatchOnlyAccounts() Logger.debug("Syncing LDK...") try await ServiceQueue.background(.ldk) { @@ -554,76 +548,74 @@ class LightningService { } } - #if !BITKIT_NOTIFICATION_EXTENSION - func reconcileWatchOnlyAccounts() async throws { - try await WatchOnlyAccountManager.shared.reconcileTracking() + func reconcileWatchOnlyAccounts() async throws { + try await WatchOnlyAccountManager.shared.reconcileTracking() + } + + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) } + let walletRecords = records.filter { $0.walletIndex == currentWalletIndex } + let managedWalletRecords = managedRecords.filter { $0.walletIndex == currentWalletIndex } + let desiredConfigs = try Self.watchOnlyAccountConfigs(records: walletRecords) - func reconcileWatchOnlyAccountTracking( - records: [WatchOnlyAccountRecord], - managedRecords: [WatchOnlyAccountRecord] - ) async throws { - guard let node else { - throw AppError(serviceError: .nodeNotSetup) + try await ServiceQueue.background(.ldk) { + let trackedAccounts = node.listOnchainWalletAccounts() + let managedKeys = Set(managedWalletRecords.map { "\($0.addressType):\($0.accountIndex)" }) + let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) + + for trackedAccount in trackedAccounts { + let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" + if managedKeys.contains(key), !desiredKeys.contains(key) { + try node.removeOnchainWalletAccount( + addressType: trackedAccount.addressType, + accountIndex: trackedAccount.accountIndex + ) + } } - let walletRecords = records.filter { $0.walletIndex == currentWalletIndex } - let managedWalletRecords = managedRecords.filter { $0.walletIndex == currentWalletIndex } - let desiredConfigs = try Self.watchOnlyAccountConfigs(records: walletRecords) - try await ServiceQueue.background(.ldk) { - let trackedAccounts = node.listOnchainWalletAccounts() - let managedKeys = Set(managedWalletRecords.map { "\($0.addressType):\($0.accountIndex)" }) - let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) - - for trackedAccount in trackedAccounts { - let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" - if managedKeys.contains(key), !desiredKeys.contains(key) { - try node.removeOnchainWalletAccount( - addressType: trackedAccount.addressType, - accountIndex: trackedAccount.accountIndex - ) - } + for config in desiredConfigs { + let isTracked = trackedAccounts.contains { + $0.addressType == config.addressType && $0.accountIndex == config.accountIndex } - - for config in desiredConfigs { - let isTracked = trackedAccounts.contains { - $0.addressType == config.addressType && $0.accountIndex == config.accountIndex - } - if !isTracked { - try node.addOnchainWalletAccount( - addressType: config.addressType, - accountIndex: config.accountIndex, - xpub: config.xpub - ) - } - try node.onchainPayment().revealReceiveAddressesToAccount( + if !isTracked { + try node.addOnchainWalletAccount( addressType: config.addressType, accountIndex: config.accountIndex, - index: Self.watchOnlyAccountHighestPreRevealedAddressIndex + xpub: config.xpub ) } + try node.onchainPayment().revealReceiveAddressesToAccount( + addressType: config.addressType, + accountIndex: config.accountIndex, + index: Self.watchOnlyAccountHighestPreRevealedAddressIndex + ) } } + } - private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { - try watchOnlyAccountConfigs(records: WatchOnlyAccountStore.enabledAccounts(for: walletIndex)) - } + private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { + try watchOnlyAccountConfigs(records: WatchOnlyAccountStore.enabledAccounts(for: walletIndex)) + } - private static func watchOnlyAccountConfigs(records: [WatchOnlyAccountRecord]) throws -> [OnchainWalletAccountConfig] { - try records.filter { - ($0.setupState == .active || $0.setupState == .authorizing) && $0.isTrackingEnabled - }.map { record in - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey - } - return OnchainWalletAccountConfig( - addressType: addressType, - accountIndex: record.accountIndex, - xpub: record.xpub - ) + private static func watchOnlyAccountConfigs(records: [WatchOnlyAccountRecord]) throws -> [OnchainWalletAccountConfig] { + try records.filter { + ($0.setupState == .active || $0.setupState == .authorizing) && $0.isTrackingEnabled + }.map { record in + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey } + return OnchainWalletAccountConfig( + addressType: addressType, + accountIndex: record.accountIndex, + xpub: record.xpub + ) } - #endif + } func newAddress() async throws -> String { guard let node else { diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index fa312b105..07efdbef1 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -55,12 +55,10 @@ protocol WatchOnlyAccountNodeHandling: AnyObject { var currentWalletIndex: Int { get } func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String func setWatchOnlyAccountTracking(accountIndex: UInt32, addressType: LDKNode.AddressType, xpub: String, enabled: Bool) async throws - #if !BITKIT_NOTIFICATION_EXTENSION - func reconcileWatchOnlyAccountTracking( - records: [WatchOnlyAccountRecord], - managedRecords: [WatchOnlyAccountRecord] - ) async throws - #endif + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws } extension LightningService: WatchOnlyAccountNodeHandling {} @@ -948,18 +946,16 @@ final class WatchOnlyAccountManager { }.value } - #if !BITKIT_NOTIFICATION_EXTENSION - func reconcileTracking() async throws { - try await lifecycleCoordinator.withLock { - let snapshot = try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults) - try await node.reconcileWatchOnlyAccountTracking( - records: snapshot.accounts, - managedRecords: snapshot.managedAccounts - ) - try WatchOnlyAccountStore.finishReconciliation(walletIndex: node.currentWalletIndex, defaults: defaults) - } + func reconcileTracking() async throws { + try await lifecycleCoordinator.withLock { + let snapshot = try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults) + try await node.reconcileWatchOnlyAccountTracking( + records: snapshot.accounts, + managedRecords: snapshot.managedAccounts + ) + try WatchOnlyAccountStore.finishReconciliation(walletIndex: node.currentWalletIndex, defaults: defaults) } - #endif + } private func reloadFromStore() throws { accounts = try WatchOnlyAccountStore.load(defaults: defaults) From 2150c6f8147037a9badc522c9c43013c81db3952 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 10:06:16 +0200 Subject: [PATCH 08/16] feat: stage Paykit server authorization --- Bitkit.xcodeproj/project.pbxproj | 4 +- .../Localization/en.lproj/Localizable.strings | 4 + Bitkit/Services/LightningService.swift | 128 +++++---- Bitkit/Services/WatchOnlyAccountService.swift | 30 +- .../PubkyAuthApprovalSheet.swift | 260 +++++++++++------- Bitkit/Views/Sheets/Sheet.swift | 15 +- BitkitTests/PubkyAuthApprovalSheetTests.swift | 25 ++ 7 files changed, 297 insertions(+), 169 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 465fa6049..eafbf77b0 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -204,14 +204,12 @@ Services/CoreService.swift, Services/GeoService.swift, Services/LightningService.swift, - Services/WatchOnlyAccountService.swift, Services/ServiceQueue.swift, Services/VssStoreIdProvider.swift, Utilities/Crypto.swift, Utilities/Errors.swift, Utilities/Keychain.swift, Utilities/LightningAmountConversion.swift, - Utilities/LocalizeHelpers.swift, Utilities/Logger.swift, Utilities/StateLocker.swift, ); @@ -773,6 +771,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) BITKIT_NOTIFICATION_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -805,6 +804,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) BITKIT_NOTIFICATION_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index f2141443a..2be3de2e6 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -671,6 +671,10 @@ "pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; "pubky_auth__watch_only_account_name_label" = "ACCOUNT NAME"; "pubky_auth__watch_only_account_name_placeholder" = "Name this account"; +"pubky_auth__watch_only_intro_approve" = "Approve"; +"pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds."; +"pubky_auth__watch_only_intro_nav_title" = "Earn"; +"pubky_auth__watch_only_intro_title" = "EARN BITCOIN\nFROM YOUR\nCONTENT"; "pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub."; "pubky_auth__trust_warning" = "Make sure you trust the service, browser, or device before authorizing with your pubky."; "pubky_auth__authorizing" = "Authorizing..."; diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index fe3ad6e51..13c88d518 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -87,7 +87,9 @@ class LightningService { let (selectedAddressType, monitoredTypes) = Self.addressTypeStateFromUserDefaults() config.addressType = selectedAddressType config.addressTypesToMonitor = monitoredTypes.filter { $0 != selectedAddressType } - config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) + #if !BITKIT_NOTIFICATION_EXTENSION + config.onchainWalletAccounts = try Self.watchOnlyAccountConfigs(walletIndex: walletIndex) + #endif let builder = Builder.fromConfig(config: config) builder.setCustomLogger(logWriter: LdkLogWriter()) @@ -262,11 +264,13 @@ class LightningService { try node.start() } - do { - try await reconcileWatchOnlyAccounts() - } catch { - Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") - } + #if !BITKIT_NOTIFICATION_EXTENSION + do { + try await reconcileWatchOnlyAccounts() + } catch { + Logger.error(error, context: "Failed to reconcile Paykit Server accounts during startup") + } + #endif await refreshChannelCache() await refreshCache() @@ -469,7 +473,9 @@ class LightningService { throw AppError(serviceError: .nodeNotSetup) } - try await reconcileWatchOnlyAccounts() + #if !BITKIT_NOTIFICATION_EXTENSION + try await reconcileWatchOnlyAccounts() + #endif Logger.debug("Syncing LDK...") try await ServiceQueue.background(.ldk) { @@ -548,74 +554,76 @@ class LightningService { } } - func reconcileWatchOnlyAccounts() async throws { - try await WatchOnlyAccountManager.shared.reconcileTracking() - } - - func reconcileWatchOnlyAccountTracking( - records: [WatchOnlyAccountRecord], - managedRecords: [WatchOnlyAccountRecord] - ) async throws { - guard let node else { - throw AppError(serviceError: .nodeNotSetup) + #if !BITKIT_NOTIFICATION_EXTENSION + func reconcileWatchOnlyAccounts() async throws { + try await WatchOnlyAccountManager.shared.reconcileTracking() } - let walletRecords = records.filter { $0.walletIndex == currentWalletIndex } - let managedWalletRecords = managedRecords.filter { $0.walletIndex == currentWalletIndex } - let desiredConfigs = try Self.watchOnlyAccountConfigs(records: walletRecords) - try await ServiceQueue.background(.ldk) { - let trackedAccounts = node.listOnchainWalletAccounts() - let managedKeys = Set(managedWalletRecords.map { "\($0.addressType):\($0.accountIndex)" }) - let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) - - for trackedAccount in trackedAccounts { - let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" - if managedKeys.contains(key), !desiredKeys.contains(key) { - try node.removeOnchainWalletAccount( - addressType: trackedAccount.addressType, - accountIndex: trackedAccount.accountIndex - ) - } + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) } + let walletRecords = records.filter { $0.walletIndex == currentWalletIndex } + let managedWalletRecords = managedRecords.filter { $0.walletIndex == currentWalletIndex } + let desiredConfigs = try Self.watchOnlyAccountConfigs(records: walletRecords) - for config in desiredConfigs { - let isTracked = trackedAccounts.contains { - $0.addressType == config.addressType && $0.accountIndex == config.accountIndex + try await ServiceQueue.background(.ldk) { + let trackedAccounts = node.listOnchainWalletAccounts() + let managedKeys = Set(managedWalletRecords.map { "\($0.addressType):\($0.accountIndex)" }) + let desiredKeys = Set(desiredConfigs.map { "\($0.addressType.stringValue):\($0.accountIndex)" }) + + for trackedAccount in trackedAccounts { + let key = "\(trackedAccount.addressType.stringValue):\(trackedAccount.accountIndex)" + if managedKeys.contains(key), !desiredKeys.contains(key) { + try node.removeOnchainWalletAccount( + addressType: trackedAccount.addressType, + accountIndex: trackedAccount.accountIndex + ) + } } - if !isTracked { - try node.addOnchainWalletAccount( + + for config in desiredConfigs { + let isTracked = trackedAccounts.contains { + $0.addressType == config.addressType && $0.accountIndex == config.accountIndex + } + if !isTracked { + try node.addOnchainWalletAccount( + addressType: config.addressType, + accountIndex: config.accountIndex, + xpub: config.xpub + ) + } + try node.onchainPayment().revealReceiveAddressesToAccount( addressType: config.addressType, accountIndex: config.accountIndex, - xpub: config.xpub + index: Self.watchOnlyAccountHighestPreRevealedAddressIndex ) } - try node.onchainPayment().revealReceiveAddressesToAccount( - addressType: config.addressType, - accountIndex: config.accountIndex, - index: Self.watchOnlyAccountHighestPreRevealedAddressIndex - ) } } - } - private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { - try watchOnlyAccountConfigs(records: WatchOnlyAccountStore.enabledAccounts(for: walletIndex)) - } + private static func watchOnlyAccountConfigs(walletIndex: Int) throws -> [OnchainWalletAccountConfig] { + try watchOnlyAccountConfigs(records: WatchOnlyAccountStore.enabledAccounts(for: walletIndex)) + } - private static func watchOnlyAccountConfigs(records: [WatchOnlyAccountRecord]) throws -> [OnchainWalletAccountConfig] { - try records.filter { - ($0.setupState == .active || $0.setupState == .authorizing) && $0.isTrackingEnabled - }.map { record in - guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { - throw WatchOnlyAccountError.invalidExtendedPublicKey + private static func watchOnlyAccountConfigs(records: [WatchOnlyAccountRecord]) throws -> [OnchainWalletAccountConfig] { + try records.filter { + ($0.setupState == .active || $0.setupState == .authorizing) && $0.isTrackingEnabled + }.map { record in + guard let addressType = LDKNode.AddressType.from(string: record.addressType) else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + return OnchainWalletAccountConfig( + addressType: addressType, + accountIndex: record.accountIndex, + xpub: record.xpub + ) } - return OnchainWalletAccountConfig( - addressType: addressType, - accountIndex: record.accountIndex, - xpub: record.xpub - ) } - } + #endif func newAddress() async throws -> String { guard let node else { diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 07efdbef1..fa312b105 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -55,10 +55,12 @@ protocol WatchOnlyAccountNodeHandling: AnyObject { var currentWalletIndex: Int { get } func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String func setWatchOnlyAccountTracking(accountIndex: UInt32, addressType: LDKNode.AddressType, xpub: String, enabled: Bool) async throws - func reconcileWatchOnlyAccountTracking( - records: [WatchOnlyAccountRecord], - managedRecords: [WatchOnlyAccountRecord] - ) async throws + #if !BITKIT_NOTIFICATION_EXTENSION + func reconcileWatchOnlyAccountTracking( + records: [WatchOnlyAccountRecord], + managedRecords: [WatchOnlyAccountRecord] + ) async throws + #endif } extension LightningService: WatchOnlyAccountNodeHandling {} @@ -946,16 +948,18 @@ final class WatchOnlyAccountManager { }.value } - func reconcileTracking() async throws { - try await lifecycleCoordinator.withLock { - let snapshot = try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults) - try await node.reconcileWatchOnlyAccountTracking( - records: snapshot.accounts, - managedRecords: snapshot.managedAccounts - ) - try WatchOnlyAccountStore.finishReconciliation(walletIndex: node.currentWalletIndex, defaults: defaults) + #if !BITKIT_NOTIFICATION_EXTENSION + func reconcileTracking() async throws { + try await lifecycleCoordinator.withLock { + let snapshot = try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults) + try await node.reconcileWatchOnlyAccountTracking( + records: snapshot.accounts, + managedRecords: snapshot.managedAccounts + ) + try WatchOnlyAccountStore.finishReconciliation(walletIndex: node.currentWalletIndex, defaults: defaults) + } } - } + #endif private func reloadFromStore() throws { accounts = try WatchOnlyAccountStore.load(defaults: defaults) diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 72358099f..20589dffc 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -24,6 +24,13 @@ func resolvePubkyApprovalLocalAuthMode( return .none } +func pubkyAuthDisplayPublicKey(_ publicKey: String?) -> String { + guard let publicKey else { return "" } + let rawKey = publicKey.hasPrefix("pubky") ? String(publicKey.dropFirst("pubky".count)) : publicKey + guard rawKey.count > 8 else { return rawKey } + return "\(rawKey.prefix(4))...\(rawKey.suffix(4))" +} + typealias OrdinaryPubkyAuthApproval = (String, String, String) async throws -> Void typealias CompanionPubkyAuthApproval = (String, Data, String) async throws -> Void @@ -104,15 +111,23 @@ struct PubkyAuthApprovalSheet: View { let config: PubkyAuthApprovalSheetItem - @State private var state: ApprovalState = .authorize + @State private var state: ApprovalState @State private var isShowingAuthCheck = false @State private var watchOnlyAccountName = "" - enum ApprovalState { + enum ApprovalState: Equatable { + case watchOnlyConsent case authorize case authorizing case success + @MainActor + mutating func approveWatchOnlyConsent() -> Bool { + guard self == .watchOnlyConsent else { return false } + self = .authorize + return true + } + @MainActor mutating func beginAuthorization() -> Bool { guard self == .authorize else { return false } @@ -121,16 +136,42 @@ struct PubkyAuthApprovalSheet: View { } } + init(config: PubkyAuthApprovalSheetItem) { + self.config = config + _state = State(initialValue: Self.initialState(for: config.request)) + } + + static func initialState(for request: PubkyAuthRequest) -> ApprovalState { + request.bitkitClaim == .watchOnlyAccountV1 ? .watchOnlyConsent : .authorize + } + private var headerTitle: String { - state == .success ? t("pubky_auth__success_title") : t("pubky_auth__title") + switch state { + case .watchOnlyConsent: + t("pubky_auth__watch_only_intro_nav_title") + case .authorize, .authorizing: + t("pubky_auth__title") + case .success: + t("pubky_auth__success_title") + } + } + + private var showsBackButton: Bool { + state == .authorize || state == .authorizing || state == .success } var body: some View { Sheet(id: .pubkyAuthApproval, data: config) { VStack(alignment: .leading, spacing: 0) { - SheetHeader(title: headerTitle, showBackButton: true) + SheetHeader( + title: headerTitle, + showBackButton: showsBackButton, + onBack: onBack + ) switch state { + case .watchOnlyConsent: + watchOnlyConsentContent case .authorize: authorizeContent case .authorizing: @@ -145,11 +186,12 @@ struct PubkyAuthApprovalSheet: View { AuthCheck( onCancel: { isShowingAuthCheck = false + state = .authorize }, onPinVerified: { isShowingAuthCheck = false Task { - await confirmAuthorize() + await performAuthorization() } } ) @@ -162,11 +204,60 @@ struct PubkyAuthApprovalSheet: View { } } - // MARK: - Authorize State (Screen 3) + // MARK: - Watch-Only Consent State (Screen 1) + + private var watchOnlyConsentContent: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Image("coin-stack") + .resizable() + .scaledToFit() + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + + Spacer() + .frame(height: 36) + + VStack(alignment: .leading, spacing: 8) { + DisplayText( + t("pubky_auth__watch_only_intro_title"), + accentColor: .blueAccent + ) + .fixedSize(horizontal: false, vertical: true) + + BodyMText(t("pubky_auth__watch_only_intro_description")) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + HStack(spacing: 16) { + CustomButton(title: t("common__cancel"), variant: .secondary) { + sheets.hideSheet() + } + .accessibilityIdentifier("PubkyAuthWatchOnlyCancel") + + CustomButton(title: t("pubky_auth__watch_only_intro_approve")) { + _ = state.approveWatchOnlyConsent() + } + .accessibilityIdentifier("PubkyAuthWatchOnlyApprove") + } + } + .frame(minHeight: geometry.size.height, alignment: .top) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + } + .accessibilityIdentifier("PubkyAuthWatchOnlyConsent") + } + + // MARK: - Authorize State (Screen 2) private var authorizeContent: some View { VStack(alignment: .leading, spacing: 0) { - approvalDetails(disablesName: false) + approvalDetails HStack(spacing: 16) { CustomButton(title: t("common__cancel"), variant: .secondary) { @@ -182,18 +273,19 @@ struct PubkyAuthApprovalSheet: View { } } - // MARK: - Authorizing State (Screen 4) + // MARK: - Authorizing State (Screen 3) private var authorizingContent: some View { VStack(alignment: .leading, spacing: 0) { - approvalDetails(disablesName: true) + approvalDetails - CustomButton(title: t("pubky_auth__authorizing"), isLoading: true) {} - .disabled(true) + BodyMSBText(t("pubky_auth__authorizing"), textColor: .white32) + .frame(maxWidth: .infinity) + .frame(height: 56) } } - // MARK: - Success State (Screen 5) + // MARK: - Success State (Screen 4) private var successContent: some View { VStack(alignment: .leading, spacing: 0) { @@ -219,32 +311,27 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Shared Components - private func approvalDetails(disablesName: Bool) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 32) + private var approvalDetails: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + descriptionText + .padding(.bottom, 32) - permissionsSection - .padding(.bottom, 16) + permissionsSection - if config.request.bitkitClaim != nil { - bitkitClaimSection - .padding(.bottom, 16) + Spacer(minLength: 32) - watchOnlyAccountNameSection - .disabled(disablesName) + trustWarning .padding(.bottom, 16) - } - - trustWarning - .padding(.bottom, 16) - profileCard - .padding(.bottom, 24) + profileCard + .padding(.bottom, 24) + } + .frame(minHeight: geometry.size.height, alignment: .top) } + .scrollIndicators(.hidden) } - .scrollIndicators(.hidden) } private var serviceText: String { @@ -260,11 +347,9 @@ struct PubkyAuthApprovalSheet: View { .lineSpacing(4) } - @ViewBuilder private var successDescriptionText: some View { - let truncatedKey = pubkyProfile.profile?.truncatedPublicKey ?? "" BodyMText( - t("pubky_auth__success_prefix") + "" + truncatedKey + "" + t("pubky_auth__success_prefix") + "" + truncatedPublicKey + "" + t("pubky_auth__success_middle") + "" + serviceText + "" + t("pubky_auth__success_suffix"), accentColor: .textPrimary, @@ -300,69 +385,38 @@ struct PubkyAuthApprovalSheet: View { } } - @ViewBuilder - private var bitkitClaimSection: some View { - switch config.request.bitkitClaim { - case .some(.watchOnlyAccountV1): - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("pubky_auth__watch_only_account_title"), textColor: .white64) - BodySText(t("pubky_auth__watch_only_account_description")) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) - } - .padding(16) - .background(Color.gray6) - .cornerRadius(16) - .accessibilityIdentifier("PubkyAuthWatchOnlyAccountClaim") - case nil: - EmptyView() - } - } - - private var watchOnlyAccountNameSection: some View { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("pubky_auth__watch_only_account_name_label"), textColor: .white64) - TextField( - t("pubky_auth__watch_only_account_name_placeholder"), - text: $watchOnlyAccountName, - testIdentifier: "PubkyAuthWatchOnlyAccountName" - ) - } - } - private var trustWarning: some View { BodySText(t("pubky_auth__trust_warning")) .lineSpacing(4) } private var profileCard: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(spacing: 16) { CaptionMText( - pubkyProfile.profile?.truncatedPublicKey ?? "", + truncatedPublicKey.localizedUppercase, textColor: .white64 ) - HStack(alignment: .top, spacing: 16) { - HeadlineText(pubkyProfile.displayName ?? "") - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - if let imageUri = pubkyProfile.displayImageUri { - PubkyImage(uri: imageUri, size: 64) - } else { - Circle() - .fill(Color.pubkyGreen) - .frame(width: 64, height: 64) - .overlay { - Image("user-square") - .resizable() - .scaledToFit() - .foregroundColor(.white32) - .frame(width: 32, height: 32) - } - } + if let imageUri = pubkyProfile.displayImageUri { + PubkyImage(uri: imageUri, size: 96) + } else { + Circle() + .fill(Color.pubkyGreen) + .frame(width: 96, height: 96) + .overlay { + Image("user-square") + .resizable() + .scaledToFit() + .foregroundColor(.white32) + .frame(width: 48, height: 48) + } } + + HeadlineText(pubkyProfile.displayName ?? "") + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity) .padding(24) .background(Color.gray6) .cornerRadius(16) @@ -372,6 +426,8 @@ struct PubkyAuthApprovalSheet: View { @MainActor private func onAuthorize() async { + guard state.beginAuthorization() else { return } + switch resolvePubkyApprovalLocalAuthMode( isPinEnabled: settings.pinEnabled, isBiometricEnabled: settings.useBiometrics, @@ -382,7 +438,7 @@ struct PubkyAuthApprovalSheet: View { case .biometrics: await authorizeWithBiometrics() case .none: - await confirmAuthorize() + await performAuthorization() } } @@ -392,18 +448,18 @@ struct PubkyAuthApprovalSheet: View { switch biometricResult { case .success: - await confirmAuthorize() + await performAuthorization() case .cancelled: - return + state = .authorize case let .failed(message): app.toast(type: .error, title: t("pubky_auth__biometric_failed"), description: message) + state = .authorize } } @MainActor - private func confirmAuthorize() async { - guard state.beginAuthorization() else { return } - + private func performAuthorization() async { + guard state == .authorizing else { return } do { guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty @@ -416,7 +472,7 @@ struct PubkyAuthApprovalSheet: View { try await approvePubkyAuthRequest( request: config.request, authUrl: config.authUrl, - accountName: watchOnlyAccountName, + accountName: effectiveWatchOnlyAccountName, secretKeyHex: secretKey ) @@ -427,4 +483,24 @@ struct PubkyAuthApprovalSheet: View { state = .authorize } } + + private var effectiveWatchOnlyAccountName: String { + let trimmedName = watchOnlyAccountName.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedName.isEmpty else { return trimmedName } + return config.request.serviceNames.first.map { + t("pubky_auth__watch_only_account_default_name", variables: ["service": $0]) + } ?? t("pubky_auth__watch_only_account_fallback_name") + } + + private var truncatedPublicKey: String { + pubkyAuthDisplayPublicKey(pubkyProfile.publicKey ?? pubkyProfile.profile?.publicKey) + } + + private func onBack() { + if state == .authorize, config.request.bitkitClaim == .watchOnlyAccountV1 { + state = .watchOnlyConsent + } else { + sheets.hideSheet() + } + } } diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift index c303c4ca7..79cffdd4b 100644 --- a/Bitkit/Views/Sheets/Sheet.swift +++ b/Bitkit/Views/Sheets/Sheet.swift @@ -50,18 +50,29 @@ struct SheetHeader: View { let title: String let showBackButton: Bool let action: AnyView? + let onBack: (() -> Void)? - init(title: String, showBackButton: Bool = false, action: AnyView? = nil) { + init( + title: String, + showBackButton: Bool = false, + action: AnyView? = nil, + onBack: (() -> Void)? = nil + ) { self.title = title self.showBackButton = showBackButton self.action = action + self.onBack = onBack } var body: some View { HStack(alignment: .center, spacing: 0) { if showBackButton { Button(action: { - dismiss() + if let onBack { + onBack() + } else { + dismiss() + } }) { Image("arrow-left") .resizable() diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index bf376c547..c523da609 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -5,6 +5,31 @@ import Paykit import XCTest final class PubkyAuthApprovalSheetTests: XCTestCase { + func testAuthDisplayPublicKeyOmitsPubkyPrefix() { + XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg") + XCTAssertEqual(pubkyAuthDisplayPublicKey("3rsd123456789w5xg"), "3rsd...w5xg") + XCTAssertEqual(pubkyAuthDisplayPublicKey(nil), "") + } + + @MainActor + func testWatchOnlyRequestStartsWithSeparateConsentBeforeAuthorization() throws { + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + var state = PubkyAuthApprovalSheet.initialState(for: request) + + XCTAssertEqual(state, .watchOnlyConsent) + XCTAssertTrue(state.approveWatchOnlyConsent()) + XCTAssertEqual(state, .authorize) + XCTAssertFalse(state.approveWatchOnlyConsent()) + } + + func testOrdinaryRequestStartsAtNormalAuthorization() throws { + let authUrl = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let request = try PubkyAuthRequest.parse(url: authUrl) + + XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize) + } + func testResolvePubkyApprovalLocalAuthModePrefersPinWhenPinEnabled() { let mode = resolvePubkyApprovalLocalAuthMode( isPinEnabled: true, From 5a770ee8395f7f63601b18e4d96669d0ae7ee7b3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 10:11:04 +0200 Subject: [PATCH 09/16] refactor: drop unpublished Paykit migration --- Bitkit/Services/WatchOnlyAccountService.swift | 40 +++++-------------- .../WatchOnlyAccountServiceTests.swift | 18 --------- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index fa312b105..5ebb458c3 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -73,7 +73,7 @@ struct WatchOnlyAccountAllocationState: Codable, Equatable { private struct WatchOnlyAccountData: Codable { var accounts: [WatchOnlyAccountRecord] = [] var allocationState = WatchOnlyAccountAllocationState() - var accountsPendingUnload: [WatchOnlyAccountRecord]? + var accountsPendingUnload: [WatchOnlyAccountRecord] = [] } struct WatchOnlyAccountBackupSnapshot { @@ -91,8 +91,6 @@ enum WatchOnlyAccountStore { static let dataKey = "watchOnlyAccountDataV1" - private static let legacyAccountsKey = "watchOnlyAccountsV1" - private static let legacyAllocationKey = "watchOnlyAccountAllocationsV1" private static let maximumAccountIndex = UInt32(Int32.max) private static let walletBackupDataChangedSubject = PassthroughSubject() @@ -131,7 +129,7 @@ enum WatchOnlyAccountStore { let restoredRecords = records ?? [] var data = (try? loadData(defaults: defaults)) ?? WatchOnlyAccountData() let currentAccounts = data.accounts - let locallyManagedAccounts = currentAccounts + (data.accountsPendingUnload ?? []) + let locallyManagedAccounts = currentAccounts + data.accountsPendingUnload let authorizingAccounts = sanitizedAccounts(locallyManagedAccounts.filter { $0.setupState == .authorizing }) let authorizingIds = Set(authorizingAccounts.map(\.id)) let authorizingKeys = Set(authorizingAccounts.map(managementKey)) @@ -163,9 +161,9 @@ enum WatchOnlyAccountStore { }) + protectedLocalAccounts let mergedKeys = Set(mergedRecords.map(managementKey)) - let accountsPendingUnload = uniqueAccounts((data.accountsPendingUnload ?? []) + currentAccounts) + let accountsPendingUnload = uniqueAccounts(data.accountsPendingUnload + currentAccounts) .filter { !mergedKeys.contains(managementKey($0)) } - data.accountsPendingUnload = accountsPendingUnload.isEmpty ? nil : accountsPendingUnload + data.accountsPendingUnload = accountsPendingUnload data.accounts = mergedRecords.sorted { $0.accountIndex < $1.accountIndex } var localAllocationState = WatchOnlyAccountAllocationState( @@ -248,17 +246,14 @@ enum WatchOnlyAccountStore { let data = try loadData(defaults: defaults) return WatchOnlyAccountReconciliationSnapshot( accounts: data.accounts.sorted { $0.accountIndex < $1.accountIndex }, - managedAccounts: uniqueAccounts(data.accounts + (data.accountsPendingUnload ?? [])) + managedAccounts: uniqueAccounts(data.accounts + data.accountsPendingUnload) ) } static func finishReconciliation(walletIndex: Int, defaults: UserDefaults = .standard) throws { var data = try loadData(defaults: defaults) - guard let accountsPendingUnload = data.accountsPendingUnload, - accountsPendingUnload.contains(where: { $0.walletIndex == walletIndex }) - else { return } - let remainingAccounts = accountsPendingUnload.filter { $0.walletIndex != walletIndex } - data.accountsPendingUnload = remainingAccounts.isEmpty ? nil : remainingAccounts + guard data.accountsPendingUnload.contains(where: { $0.walletIndex == walletIndex }) else { return } + data.accountsPendingUnload = data.accountsPendingUnload.filter { $0.walletIndex != walletIndex } try saveData(data, defaults: defaults) } @@ -321,28 +316,11 @@ enum WatchOnlyAccountStore { static func clear(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: dataKey) - defaults.removeObject(forKey: legacyAccountsKey) - defaults.removeObject(forKey: legacyAllocationKey) } private static func loadData(defaults: UserDefaults) throws -> WatchOnlyAccountData { - if let encoded = defaults.data(forKey: dataKey) { - return try JSONDecoder().decode(WatchOnlyAccountData.self, from: encoded) - } - - let legacyAccountsData = defaults.data(forKey: legacyAccountsKey) - let legacyAllocationData = defaults.data(forKey: legacyAllocationKey) - guard legacyAccountsData != nil || legacyAllocationData != nil else { - return WatchOnlyAccountData() - } - - let accounts = try legacyAccountsData.map { try JSONDecoder().decode([WatchOnlyAccountRecord].self, from: $0) } ?? [] - var allocationState = try legacyAllocationData.map { - try JSONDecoder().decode(WatchOnlyAccountAllocationState.self, from: $0) - } ?? WatchOnlyAccountAllocationState() - allocationState.reconcileAccountIndexes(accounts) - - return WatchOnlyAccountData(accounts: accounts, allocationState: allocationState) + guard let encoded = defaults.data(forKey: dataKey) else { return WatchOnlyAccountData() } + return try JSONDecoder().decode(WatchOnlyAccountData.self, from: encoded) } private static func saveData(_ data: WatchOnlyAccountData, defaults: UserDefaults) throws { diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 7d7d70925..e52df93c0 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -821,24 +821,6 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ), 6) } - func testLegacyLoadDoesNotWriteDuringRead() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - let legacyRecord = makeRecord( - accountIndex: 3, - xpub: base58CheckEncode(Data(repeating: 1, count: 78)), - setupState: .active - ) - try defaults.set(JSONEncoder().encode([legacyRecord]), forKey: "watchOnlyAccountsV1") - - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [legacyRecord]) - XCTAssertNil(defaults.data(forKey: WatchOnlyAccountStore.dataKey)) - - try WatchOnlyAccountStore.save([legacyRecord], defaults: defaults) - XCTAssertNotNil(defaults.data(forKey: WatchOnlyAccountStore.dataKey)) - } - func testReconciliationClearsPendingUnloadsOnlyForCurrentWallet() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) From 33c07bcf86a08f304ff7f4076b0409a086cbe35f Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 10:47:43 +0200 Subject: [PATCH 10/16] refactor: simplify watch-only account lifecycle --- Bitkit/Services/LightningService.swift | 314 ++++---- Bitkit/Services/PubkyService.swift | 71 ++ Bitkit/Services/WatchOnlyAccountService.swift | 377 ++++------ .../PubkyAuthApprovalSheet.swift | 68 +- BitkitTests/PubkyAuthApprovalSheetTests.swift | 106 +-- .../WatchOnlyAccountServiceTests.swift | 682 ++---------------- Docs/watch-only-account-claim-v1.md | 2 +- 7 files changed, 430 insertions(+), 1190 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 13c88d518..d2d25862c 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1334,197 +1334,193 @@ extension LightningService { extension LightningService { func listenForEvents(onEvent: ((Event) -> Void)? = nil) { - Task { [weak self] in - guard let self else { return } - await listenForEventsLoop(onEvent: onEvent) - } - } - - private func listenForEventsLoop(onEvent: ((Event) -> Void)?) async { - while true { - guard let node else { - Logger.error("LDK node not started") - return - } + Task { + while true { + guard let node = self.node else { + Logger.error("LDK node not started") + return + } - let event = await node.nextEventAsync() + let event = await node.nextEventAsync() - do { - try node.eventHandled() - } catch { - Logger.error(error, context: "node.eventHandled()") - } + do { + try node.eventHandled() + } catch { + Logger.error(error, context: "node.eventHandled()") + } - if case .channelReady = event { - await refreshChannelCache() - } + if case .channelReady = event { + await refreshChannelCache() + } - onEvent?(event) + onEvent?(event) - switch event { - case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): - Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)") - Task { - let hash = paymentId ?? paymentHash - do { - try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) - } catch { - Logger.error("Failed to handle payment success for \(hash): \(error)", context: "LightningService") - } - } - case let .paymentFailed(paymentId, paymentHash, reason): - Logger.info( - "❌ Payment failed: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash ?? "") reason: \(reason.debugDescription)" - ) - Task { - if let hash = paymentId ?? paymentHash { + switch event { + case let .paymentSuccessful(paymentId, paymentHash, _, feePaidMsat): + Logger.info("✅ Payment successful: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) feePaidMsat: \(feePaidMsat ?? 0)") + Task { + let hash = paymentId ?? paymentHash do { try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) } catch { - Logger.error("Failed to handle payment failure for \(hash): \(error)", context: "LightningService") + Logger.error("Failed to handle payment success for \(hash): \(error)", context: "LightningService") } - } else { - Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService") } - } - case let .paymentReceived(paymentId, paymentHash, amountMsat, _): - Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)") - Task { - let hash = paymentId ?? paymentHash - do { - try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) - } catch { - Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService") + case let .paymentFailed(paymentId, paymentHash, reason): + Logger.info( + "❌ Payment failed: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash ?? "") reason: \(reason.debugDescription)" + ) + Task { + if let hash = paymentId ?? paymentHash { + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment failure for \(hash): \(error)", context: "LightningService") + } + } else { + Logger.warn("No paymentId or paymentHash available for failed payment", context: "LightningService") + } } - } - // Claimable payments remain pending until paymentSuccessful or paymentFailed updates their activity. - case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, _, _): - Logger.info( - "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" - ) - case let .probeSuccessful(paymentId, paymentHash, _): - Logger.info("🤑 Probe successful: paymentId: \(paymentId) paymentHash: \(paymentHash)") - case let .probeFailed(paymentId, paymentHash, shortChannelId, _): - Logger - .info( - "❌ Probe failed: paymentId: \(paymentId) paymentHash: \(paymentHash) shortChannelId: \(String(describing: shortChannelId))" + case let .paymentReceived(paymentId, paymentHash, amountMsat, _): + Logger.info("🤑 Payment received: paymentId: \(paymentId ?? "?") paymentHash: \(paymentHash) amountMsat: \(amountMsat)") + Task { + let hash = paymentId ?? paymentHash + do { + try await CoreService.shared.activity.handlePaymentEvent(paymentHash: hash) + } catch { + Logger.error("Failed to handle payment received for \(hash): \(error)", context: "LightningService") + } + } + case let .paymentClaimable(paymentId, paymentHash, claimableAmountMsat, _, _): + Logger.info( + "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" + ) + case let .probeSuccessful(paymentId, paymentHash, _): + Logger.info("🤑 Probe successful: paymentId: \(paymentId) paymentHash: \(paymentHash)") + case let .probeFailed(paymentId, paymentHash, shortChannelId, _): + Logger + .info( + "❌ Probe failed: paymentId: \(paymentId) paymentHash: \(paymentHash) shortChannelId: \(String(describing: shortChannelId))" + ) + // Payment claimable doesn't need activity update - it's still pending + // The payment will be updated when it succeeds or fails via paymentSuccessful/paymentFailed events + case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): + Logger.info( + "⏳ Channel pending: channelId: \(channelId) userChannelId: \(userChannelId) formerTemporaryChannelId: \(formerTemporaryChannelId) counterpartyNodeId: \(counterpartyNodeId) fundingTxo: \(fundingTxo)" + ) + await refreshChannelCache() + case let .channelReady(channelId, userChannelId, counterpartyNodeId, fundingTxo): + Logger.info( + "👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") fundingTxo: \(fundingTxo != nil ? "\(fundingTxo!.txid):\(fundingTxo!.vout)" : "nil")" + ) + case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason): + let reasonString = reason.map { String(describing: $0) } ?? "" + Logger.info( + "⛔ Channel closed: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") reason: \(reasonString)" ) - case let .channelPending(channelId, userChannelId, formerTemporaryChannelId, counterpartyNodeId, fundingTxo): - Logger.info( - "⏳ Channel pending: channelId: \(channelId) userChannelId: \(userChannelId) formerTemporaryChannelId: \(formerTemporaryChannelId) counterpartyNodeId: \(counterpartyNodeId) fundingTxo: \(fundingTxo)" - ) - await refreshChannelCache() - case let .channelReady(channelId, userChannelId, counterpartyNodeId, fundingTxo): - Logger.info( - "👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") fundingTxo: \(fundingTxo != nil ? "\(fundingTxo!.txid):\(fundingTxo!.vout)" : "nil")" - ) - case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason): - let reasonString = reason.map { String(describing: $0) } ?? "" - Logger.info( - "⛔ Channel closed: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") reason: \(reasonString)" - ) - let channelIdString = channelId.description - let channel = await MainActor.run { - channelCache[channelIdString] - } + let channelIdString = channelId.description + let channel = await MainActor.run { + channelCache[channelIdString] + } - if let channel { - await registerClosedChannel(channel: channel, reason: reasonString) - _ = await MainActor.run { - channelCache.removeValue(forKey: channelIdString) + if let channel { + await registerClosedChannel(channel: channel, reason: reasonString) + _ = await MainActor.run { + channelCache.removeValue(forKey: channelIdString) + } + } else { + Logger.error( + "Could not find channel details for closed channel: channelId=\(channelIdString) userChannelId=\(userChannelId) in cache", + context: "LightningService" + ) } - } else { - Logger.error( - "Could not find channel details for closed channel: channelId=\(channelIdString) userChannelId=\(userChannelId) in cache", - context: "LightningService" - ) - } - case .paymentForwarded: - break + case .paymentForwarded: + break // MARK: New Onchain Transaction Events - case let .onchainTransactionReceived(txid, details): - Logger.info("📥 Onchain transaction received: txid=\(txid) amountSats=\(details.amountSats)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionReceived(txid: txid, details: details) - } catch { - Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService") + case let .onchainTransactionReceived(txid, details): + Logger.info("📥 Onchain transaction received: txid=\(txid) amountSats=\(details.amountSats)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReceived(txid: txid, details: details) + } catch { + Logger.error("Failed to handle transaction received for \(txid): \(error)", context: "LightningService") + } } - } - case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): - Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionConfirmed( - txid: txid, - details: details - ) - } catch { - Logger.error("Failed to handle transaction confirmed for \(txid): \(error)", context: "LightningService") + case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): + Logger.info("✅ Onchain transaction confirmed: txid=\(txid) blockHeight=\(blockHeight) amountSats=\(details.amountSats)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionConfirmed( + txid: txid, + details: details + ) + } catch { + Logger.error("Failed to handle transaction confirmed for \(txid): \(error)", context: "LightningService") + } } - } - case let .onchainTransactionReplaced(txid, conflicts): - Logger.info("🔄 Onchain transaction replaced (RBF): txid=\(txid) by \(conflicts.count) conflict(s)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionReplaced(txid: txid, conflicts: conflicts) - } catch { - Logger.error("Failed to handle transaction replaced for \(txid): \(error)", context: "LightningService") + case let .onchainTransactionReplaced(txid, conflicts): + Logger.info("🔄 Onchain transaction replaced (RBF): txid=\(txid) by \(conflicts.count) conflict(s)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReplaced(txid: txid, conflicts: conflicts) + } catch { + Logger.error("Failed to handle transaction replaced for \(txid): \(error)", context: "LightningService") + } } - } - case let .onchainTransactionReorged(txid): - Logger.warn("⚠️ Onchain transaction reorged (unconfirmed): txid=\(txid)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionReorged(txid: txid) - } catch { - Logger.error("Failed to handle transaction reorged for \(txid): \(error)", context: "LightningService") + case let .onchainTransactionReorged(txid): + Logger.warn("⚠️ Onchain transaction reorged (unconfirmed): txid=\(txid)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionReorged(txid: txid) + } catch { + Logger.error("Failed to handle transaction reorged for \(txid): \(error)", context: "LightningService") + } } - } - case let .onchainTransactionEvicted(txid): - Logger.warn("🗑️ Onchain transaction removed from mempool: txid=\(txid)") - Task { - do { - try await CoreService.shared.activity.handleOnchainTransactionEvicted(txid: txid) - } catch { - Logger.error("Failed to handle transaction evicted for \(txid): \(error)", context: "LightningService") + case let .onchainTransactionEvicted(txid): + Logger.warn("🗑️ Onchain transaction removed from mempool: txid=\(txid)") + Task { + do { + try await CoreService.shared.activity.handleOnchainTransactionEvicted(txid: txid) + } catch { + Logger.error("Failed to handle transaction evicted for \(txid): \(error)", context: "LightningService") + } } - } // MARK: Sync Events - case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): - Logger - .debug( - "🔄 Sync progress: type=\(syncType) progress=\(progressPercent)% current=\(currentBlockHeight) target=\(targetBlockHeight)" - ) - case let .syncCompleted(syncType, syncedBlockHeight): - Logger.info("✅ Sync completed: type=\(syncType) height=\(syncedBlockHeight)") - // Send sync status update - PassthroughSubject is thread-safe - syncStatusChangedSubject.send(UInt64(Date().timeIntervalSince1970)) + case let .syncProgress(syncType, progressPercent, currentBlockHeight, targetBlockHeight): + Logger + .debug( + "🔄 Sync progress: type=\(syncType) progress=\(progressPercent)% current=\(currentBlockHeight) target=\(targetBlockHeight)" + ) + case let .syncCompleted(syncType, syncedBlockHeight): + Logger.info("✅ Sync completed: type=\(syncType) height=\(syncedBlockHeight)") + // Send sync status update - PassthroughSubject is thread-safe + syncStatusChangedSubject.send(UInt64(Date().timeIntervalSince1970)) // MARK: Balance Events - case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, _, _, oldLightning, newLightning): - Logger - .info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)") + case let .balanceChanged(oldSpendableOnchain, newSpendableOnchain, _, _, oldLightning, newLightning): + Logger + .info("💰 Balance changed: onchain=\(oldSpendableOnchain)->\(newSpendableOnchain) lightning=\(oldLightning)->\(newLightning)") // MARK: Splice Events - case let .splicePending(channelId, userChannelId, counterpartyNodeId, newFundingTxo): - Logger - .info( - "🔀 Splice pending: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) newFundingTxo=\(newFundingTxo)" - ) - await refreshChannelCache() - case let .spliceFailed(channelId, userChannelId, counterpartyNodeId, abandonedFundingTxo): - Logger - .warn( - "❌ Splice failed: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) abandonedFundingTxo=\(abandonedFundingTxo != nil ? "\(abandonedFundingTxo!.txid):\(abandonedFundingTxo!.vout)" : "nil")" - ) + case let .splicePending(channelId, userChannelId, counterpartyNodeId, newFundingTxo): + Logger + .info( + "🔀 Splice pending: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) newFundingTxo=\(newFundingTxo)" + ) + await refreshChannelCache() + case let .spliceFailed(channelId, userChannelId, counterpartyNodeId, abandonedFundingTxo): + Logger + .warn( + "❌ Splice failed: channelId=\(channelId) userChannelId=\(userChannelId) counterpartyNodeId=\(counterpartyNodeId) abandonedFundingTxo=\(abandonedFundingTxo != nil ? "\(abandonedFundingTxo!.txid):\(abandonedFundingTxo!.vout)" : "nil")" + ) + } } } } diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 15539cb10..349909572 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -117,6 +117,77 @@ enum PubkyService { return false } + typealias OrdinaryAuthApproval = (String, String, String) async throws -> Void + typealias CompanionAuthApproval = (String, Data, String) async throws -> Void + + @MainActor + static func approveAuthRequest( + request: PubkyAuthRequest, + authUrl: String, + accountName: String, + secretKeyHex: String, + accountManager: WatchOnlyAccountManager? = nil, + ordinaryApproval: @escaping OrdinaryAuthApproval = { authUrl, capabilities, secretKeyHex in + try await approveAuth( + authUrl: authUrl, + expectedCapabilities: capabilities, + secretKeyHex: secretKeyHex + ) + }, + companionApproval: @escaping CompanionAuthApproval = { authUrl, unsignedPayload, secretKeyHex in + try await approveAuthWithCompanionClaim( + authUrl: authUrl, + unsignedPayload: unsignedPayload, + secretKeyHex: secretKeyHex + ) + } + ) async throws { + let accountManager = accountManager ?? .shared + if request.bitkitClaim == .watchOnlyAccountV1 { + let preparedClaim = try await accountManager.prepareUnsignedClaim(authUrl: authUrl, name: accountName) + let authorizationAttempt = try accountManager.acquireSetupAuthorizationAttempt(id: preparedClaim.0.id) + defer { accountManager.finishSetupAuthorizationAttempt(authorizationAttempt) } + + do { + try await accountManager.beginSetupAuthorization(attempt: authorizationAttempt) + } catch { + await cancelIncompleteAuthorization( + accountManager: accountManager, + authorizationAttempt: authorizationAttempt + ) + throw error + } + + do { + try await companionApproval(authUrl, preparedClaim.1, secretKeyHex) + } catch { + if !didDeliverCompanionClaim(error: error) { + await cancelIncompleteAuthorization( + accountManager: accountManager, + authorizationAttempt: authorizationAttempt + ) + } + throw error + } + + try await accountManager.markSetupActive(attempt: authorizationAttempt) + } else { + try await ordinaryApproval(authUrl, request.capabilities, secretKeyHex) + } + } + + @MainActor + private static func cancelIncompleteAuthorization( + accountManager: WatchOnlyAccountManager, + authorizationAttempt: WatchOnlyAccountAuthorizationAttempt + ) async { + do { + try await accountManager.cancelSetupAuthorization(attempt: authorizationAttempt) + } catch { + Logger.error("Failed to unload incomplete watch-only account: \(error)", context: "PubkyService") + } + } + // MARK: - Key Derivation /// Derive an Ed25519 secret key from a BIP39 mnemonic. Returns hex-encoded 32-byte key. diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 5ebb458c3..687b4e754 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -126,119 +126,59 @@ enum WatchOnlyAccountStore { allocationState restoredAllocationState: WatchOnlyAccountAllocationState? = nil, defaults: UserDefaults = .standard ) throws { - let restoredRecords = records ?? [] + let restoredInput = records ?? [] + let restoredAccounts = sanitizedAccounts(restoredInput) var data = (try? loadData(defaults: defaults)) ?? WatchOnlyAccountData() let currentAccounts = data.accounts - let locallyManagedAccounts = currentAccounts + data.accountsPendingUnload - let authorizingAccounts = sanitizedAccounts(locallyManagedAccounts.filter { $0.setupState == .authorizing }) - let authorizingIds = Set(authorizingAccounts.map(\.id)) - let authorizingKeys = Set(authorizingAccounts.map(managementKey)) - let authorizingRequestKeys = Set(authorizingAccounts.map { - allocationRequestKey(walletIndex: $0.walletIndex, requestFingerprint: $0.requestFingerprint) - }) - let protectedRestorationConflicts = sanitizedAccounts(locallyManagedAccounts.filter { account in - account.setupState != .authorizing - && !authorizingIds.contains(account.id) - && !authorizingKeys.contains(managementKey(account)) - && (account.setupState == .active || !authorizingRequestKeys.contains( - allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) - )) - && shouldProtectLocalAccount(account, from: restoredRecords) - }).map { promotedTrackingState(for: $0, from: restoredRecords) } - let protectedLocalAccounts = authorizingAccounts + protectedRestorationConflicts - let protectedIds = Set(protectedLocalAccounts.map(\.id)) - let protectedKeys = Set(protectedLocalAccounts.map(managementKey)) - let protectedIncompleteRequestKeys = Set(protectedLocalAccounts.compactMap { account -> String? in - guard account.setupState != .active else { return nil } - return allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) + let locallyManagedAccounts = uniqueAccounts(currentAccounts + data.accountsPendingUnload) + + let protectedLocalAccounts = sanitizedAccounts(locallyManagedAccounts.filter { localAccount in + localAccount.setupState == .authorizing + || shouldPreserveLocalAccount(localAccount, from: restoredAccounts) }) - let mergedRecords = sanitizedAccounts(restoredRecords.filter { - !protectedIds.contains($0.id) - && !protectedKeys.contains(managementKey($0)) - && ($0.setupState == .active || !protectedIncompleteRequestKeys.contains( - allocationRequestKey(walletIndex: $0.walletIndex, requestFingerprint: $0.requestFingerprint) - )) - }) + protectedLocalAccounts - let mergedKeys = Set(mergedRecords.map(managementKey)) - - let accountsPendingUnload = uniqueAccounts(data.accountsPendingUnload + currentAccounts) - .filter { !mergedKeys.contains(managementKey($0)) } - data.accountsPendingUnload = accountsPendingUnload - data.accounts = mergedRecords.sorted { $0.accountIndex < $1.accountIndex } + let mergedAccounts = sanitizedAccounts(protectedLocalAccounts + restoredAccounts) + let mergedManagementKeys = Set(mergedAccounts.map(managementKey)) + + data.accountsPendingUnload = uniqueAccounts(currentAccounts + data.accountsPendingUnload) + .filter { !mergedManagementKeys.contains(managementKey($0)) } + data.accounts = mergedAccounts var localAllocationState = WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: data.allocationState.highestAccountIndexByWallet.filter { - isValidAccountIndex($0.value) - }, - pendingAccountIndexByRequest: data.allocationState.pendingAccountIndexByRequest.filter { - isValidAccountIndex($0.value) - } + highestAccountIndexByWallet: validHighestAccountIndexes(data.allocationState.highestAccountIndexByWallet), + pendingAccountIndexByRequest: validPendingAccountIndexes(data.allocationState.pendingAccountIndexByRequest) ) localAllocationState.reconcileAccountIndexes(locallyManagedAccounts) - for (requestKey, accountIndex) in localAllocationState.pendingAccountIndexByRequest { - guard let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) else { continue } - let walletKey = String(walletIndex) - localAllocationState.highestAccountIndexByWallet[walletKey] = max( - localAllocationState.highestAccountIndexByWallet[walletKey] ?? 0, - accountIndex - ) - } - let localHighestAccountIndexByWallet = localAllocationState.highestAccountIndexByWallet - var highestAccountIndexByWallet = localHighestAccountIndexByWallet + localAllocationState.raiseHighWaterMarksForPendingReservations() - if let restoredAllocationState { - for (walletKey, restoredIndex) in restoredAllocationState.highestAccountIndexByWallet { - guard isValidAccountIndex(restoredIndex) else { continue } - highestAccountIndexByWallet[walletKey] = max( - highestAccountIndexByWallet[walletKey] ?? 0, - restoredIndex - ) - } - for (requestKey, restoredIndex) in restoredAllocationState.pendingAccountIndexByRequest { - guard isValidAccountIndex(restoredIndex), - let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) - else { continue } - let walletKey = String(walletIndex) - highestAccountIndexByWallet[walletKey] = max( - highestAccountIndexByWallet[walletKey] ?? 0, - restoredIndex - ) - } - } - - var pendingAccountIndexByRequest = validPendingAccountIndexes( - restoredAllocationState?.pendingAccountIndexByRequest ?? [:], - accounts: mergedRecords, - restoredAccounts: restoredRecords, - blockedAccounts: accountsPendingUnload, - localHighestAccountIndexByWallet: localHighestAccountIndexByWallet, - localPendingAccountIndexByRequest: localAllocationState.pendingAccountIndexByRequest - ) - for account in authorizingAccounts { - let accountSlot = allocationSlotKey(walletIndex: account.walletIndex, accountIndex: account.accountIndex) - pendingAccountIndexByRequest = pendingAccountIndexByRequest.filter { requestKey, accountIndex in - guard let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) else { return false } - return allocationSlotKey(walletIndex: walletIndex, accountIndex: accountIndex) != accountSlot - } - let requestKey = allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) - pendingAccountIndexByRequest[requestKey] = account.accountIndex - } - - for (requestKey, accountIndex) in pendingAccountIndexByRequest { - guard let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) else { continue } - let walletKey = String(walletIndex) + var highestAccountIndexByWallet = localAllocationState.highestAccountIndexByWallet + for (walletKey, restoredIndex) in validHighestAccountIndexes( + restoredAllocationState?.highestAccountIndexByWallet ?? [:] + ) { highestAccountIndexByWallet[walletKey] = max( highestAccountIndexByWallet[walletKey] ?? 0, - accountIndex + restoredIndex ) } - data.allocationState = WatchOnlyAccountAllocationState( + var allocationState = WatchOnlyAccountAllocationState( highestAccountIndexByWallet: highestAccountIndexByWallet, - pendingAccountIndexByRequest: pendingAccountIndexByRequest + pendingAccountIndexByRequest: [:] ) + allocationState.reconcileAccountIndexes(locallyManagedAccounts + restoredInput + data.accountsPendingUnload) + + let retainedLocalPendingReservations = restoredAllocationState == nil + ? [:] + : localAllocationState.pendingAccountIndexByRequest + allocationState.pendingAccountIndexByRequest = mergedPendingAccountIndexes( + accounts: mergedAccounts, + blockedAccounts: data.accountsPendingUnload, + localPendingAccountIndexes: retainedLocalPendingReservations, + restoredPendingAccountIndexes: restoredAllocationState?.pendingAccountIndexByRequest ?? [:], + localHighestAccountIndexByWallet: localAllocationState.highestAccountIndexByWallet + ) + allocationState.raiseHighWaterMarksForPendingReservations() - data.allocationState.reconcileAccountIndexes(restoredRecords + mergedRecords + accountsPendingUnload) + data.allocationState = allocationState try saveData(data, defaults: defaults) } @@ -290,14 +230,6 @@ enum WatchOnlyAccountStore { return accountIndex } - static func completeAllocation(walletIndex: Int, requestFingerprint: String, defaults: UserDefaults = .standard) throws { - var data = try loadData(defaults: defaults) - data.allocationState.pendingAccountIndexByRequest.removeValue( - forKey: allocationRequestKey(walletIndex: walletIndex, requestFingerprint: requestFingerprint) - ) - try saveData(data, defaults: defaults) - } - static func markSetupActive(id: UUID, defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { var data = try loadData(defaults: defaults) guard let index = data.accounts.firstIndex(where: { $0.id == id }) else { @@ -350,100 +282,109 @@ enum WatchOnlyAccountStore { accountIndex > 0 && accountIndex <= maximumAccountIndex } - private static func validPendingAccountIndexes( - _ pendingAccountIndexes: [String: UInt32], + private static func validHighestAccountIndexes(_ indexes: [String: UInt32]) -> [String: UInt32] { + indexes.reduce(into: [:]) { result, entry in + guard let walletIndex = Int(entry.key), + walletIndex >= 0, + isValidAccountIndex(entry.value) + else { return } + let walletKey = String(walletIndex) + result[walletKey] = max(result[walletKey] ?? 0, entry.value) + } + } + + private static func validPendingAccountIndexes(_ indexes: [String: UInt32]) -> [String: UInt32] { + indexes.filter { + isValidAccountIndex($0.value) && walletIndex(fromAllocationRequestKey: $0.key) != nil + } + } + + private static func mergedPendingAccountIndexes( accounts: [WatchOnlyAccountRecord], - restoredAccounts: [WatchOnlyAccountRecord], blockedAccounts: [WatchOnlyAccountRecord], - localHighestAccountIndexByWallet: [String: UInt32], - localPendingAccountIndexByRequest: [String: UInt32] + localPendingAccountIndexes: [String: UInt32], + restoredPendingAccountIndexes: [String: UInt32], + localHighestAccountIndexByWallet: [String: UInt32] ) -> [String: UInt32] { - let accountsBySlot = Dictionary(grouping: accounts) { - allocationSlotKey(walletIndex: $0.walletIndex, accountIndex: $0.accountIndex) - } - let incompleteAccountSlotsByRequest: [String: String] = Dictionary( - uniqueKeysWithValues: accounts.compactMap { account -> (String, String)? in - guard account.setupState != .active else { return nil } - let requestKey = allocationRequestKey( - walletIndex: account.walletIndex, - requestFingerprint: account.requestFingerprint - ) - return (requestKey, allocationSlotKey(walletIndex: account.walletIndex, accountIndex: account.accountIndex)) - } - ) - let restoredIncompleteRequestKeys = Set(restoredAccounts.compactMap { account -> String? in - guard account.setupState != .active else { return nil } - return allocationRequestKey(walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint) - }) - let restoredAccountSlots = Set(restoredAccounts.map { + let activeSlots = Set(accounts.filter { $0.setupState == .active }.map { allocationSlotKey(walletIndex: $0.walletIndex, accountIndex: $0.accountIndex) }) let blockedSlots = Set(blockedAccounts.map { allocationSlotKey(walletIndex: $0.walletIndex, accountIndex: $0.accountIndex) }) - let candidates = pendingAccountIndexes.compactMap { requestKey, accountIndex -> (String, UInt32, String)? in - guard isValidAccountIndex(accountIndex), + var pendingAccountIndexes: [String: UInt32] = [:] + var reservedSlots = Set() + + func reserve(requestKey: String, accountIndex: UInt32, allowsHistoricalIndex: Bool) { + guard pendingAccountIndexes[requestKey] == nil, + isValidAccountIndex(accountIndex), let walletIndex = walletIndex(fromAllocationRequestKey: requestKey) - else { return nil } + else { return } let slot = allocationSlotKey(walletIndex: walletIndex, accountIndex: accountIndex) - let incompleteAccountSlot = incompleteAccountSlotsByRequest[requestKey] - guard !restoredIncompleteRequestKeys.contains(requestKey) || incompleteAccountSlot != nil, - incompleteAccountSlot == nil || incompleteAccountSlot == slot, - !restoredAccountSlots.contains(slot) || incompleteAccountSlot == slot - else { return nil } - if let localAccountIndex = localPendingAccountIndexByRequest[requestKey], - localAccountIndex != accountIndex - { - return nil - } - let slotAccounts = accountsBySlot[slot] ?? [] - if slotAccounts.isEmpty { - guard !blockedSlots.contains(slot), - localPendingAccountIndexByRequest[requestKey] == accountIndex - || accountIndex > (localHighestAccountIndexByWallet[String(walletIndex)] ?? 0) - else { return nil } - } else { - guard slotAccounts.allSatisfy({ - $0.setupState != .active - && allocationRequestKey(walletIndex: $0.walletIndex, requestFingerprint: $0.requestFingerprint) == requestKey - }) else { return nil } - } - return (requestKey, accountIndex, slot) + guard !reservedSlots.contains(slot), + !activeSlots.contains(slot), + !blockedSlots.contains(slot), + allowsHistoricalIndex || accountIndex > (localHighestAccountIndexByWallet[String(walletIndex)] ?? 0) + else { return } + pendingAccountIndexes[requestKey] = accountIndex + reservedSlots.insert(slot) + } + + for account in accounts where account.setupState != .active { + reserve( + requestKey: allocationRequestKey( + walletIndex: account.walletIndex, + requestFingerprint: account.requestFingerprint + ), + accountIndex: account.accountIndex, + allowsHistoricalIndex: true + ) } - - return Dictionary(grouping: candidates, by: { $0.2 }).values.reduce(into: [:]) { validIndexes, slotCandidates in - guard slotCandidates.count == 1, let candidate = slotCandidates.first else { return } - validIndexes[candidate.0] = candidate.1 + for (requestKey, accountIndex) in localPendingAccountIndexes.sorted(by: { $0.key < $1.key }) { + reserve(requestKey: requestKey, accountIndex: accountIndex, allowsHistoricalIndex: true) + } + for (requestKey, accountIndex) in restoredPendingAccountIndexes.sorted(by: { $0.key < $1.key }) { + reserve(requestKey: requestKey, accountIndex: accountIndex, allowsHistoricalIndex: false) } - } - private static func uniqueAccounts(_ accounts: [WatchOnlyAccountRecord]) -> [WatchOnlyAccountRecord] { - Dictionary(grouping: accounts, by: managementKey).values.compactMap(\.last).sorted { $0.accountIndex < $1.accountIndex } + return pendingAccountIndexes } private static func sanitizedAccounts(_ accounts: [WatchOnlyAccountRecord]) -> [WatchOnlyAccountRecord] { var ids = Set() var managementKeys = Set() var incompleteRequestKeys = Set() + var sanitized: [WatchOnlyAccountRecord] = [] - return accounts.filter(isUsableAccount).sorted(by: accountRestorationOrder).filter { account in + for input in accounts where isUsableAccount(input) { + let account = normalizedTrackingState(input) let accountManagementKey = managementKey(account) - let incompleteRequestKey = allocationRequestKey( + let requestKey = allocationRequestKey( walletIndex: account.walletIndex, requestFingerprint: account.requestFingerprint ) guard !ids.contains(account.id), !managementKeys.contains(accountManagementKey), - account.setupState == .active || !incompleteRequestKeys.contains(incompleteRequestKey) - else { return false } - + account.setupState == .active || !incompleteRequestKeys.contains(requestKey) + else { continue } ids.insert(account.id) managementKeys.insert(accountManagementKey) if account.setupState != .active { - incompleteRequestKeys.insert(incompleteRequestKey) + incompleteRequestKeys.insert(requestKey) } - return true - }.map(normalizedTrackingState) + sanitized.append(account) + } + + return sanitized.sorted { + ($0.walletIndex, $0.accountIndex, $0.createdAt) < ($1.walletIndex, $1.accountIndex, $1.createdAt) + } + } + + private static func uniqueAccounts(_ accounts: [WatchOnlyAccountRecord]) -> [WatchOnlyAccountRecord] { + var managementKeys = Set() + return accounts.filter { managementKeys.insert(managementKey($0)).inserted }.sorted { + ($0.walletIndex, $0.accountIndex) < ($1.walletIndex, $1.accountIndex) + } } private static func isUsableAccount(_ account: WatchOnlyAccountRecord) -> Bool { @@ -466,39 +407,19 @@ enum WatchOnlyAccountStore { return account } - private static func shouldProtectLocalAccount( + private static func shouldPreserveLocalAccount( _ localAccount: WatchOnlyAccountRecord, from restoredAccounts: [WatchOnlyAccountRecord] ) -> Bool { - let conflicts = restoredAccounts.filter(isUsableAccount).filter { - managementKey($0) == managementKey(localAccount) || $0.id == localAccount.id - } - guard let highestRestoredPriority = conflicts.map({ setupStatePriority($0.setupState) }).max() else { - return false - } - let localPriority = setupStatePriority(localAccount.setupState) - if localPriority != highestRestoredPriority { - return localPriority > highestRestoredPriority + let conflicts = restoredAccounts.filter { + $0.id == localAccount.id || managementKey($0) == managementKey(localAccount) } - return conflicts.contains { - setupStatePriority($0.setupState) == highestRestoredPriority - && !hasSameOwner(localAccount, $0) + guard !conflicts.isEmpty else { return false } + if conflicts.contains(where: { !hasSameOwner(localAccount, $0) }) { + return true } - } - - private static func promotedTrackingState( - for localAccount: WatchOnlyAccountRecord, - from restoredAccounts: [WatchOnlyAccountRecord] - ) -> WatchOnlyAccountRecord { - guard localAccount.setupState == .active, - restoredAccounts.filter(isUsableAccount).contains(where: { - managementKey($0) == managementKey(localAccount) - && ($0.setupState == .authorizing || $0.setupState == .active && $0.isTrackingEnabled) - }) - else { return localAccount } - var localAccount = localAccount - localAccount.isTrackingEnabled = true - return localAccount + return localAccount.setupState == .active + && conflicts.allSatisfy { $0.setupState != .active } } private static func hasSameOwner(_ lhs: WatchOnlyAccountRecord, _ rhs: WatchOnlyAccountRecord) -> Bool { @@ -507,29 +428,6 @@ enum WatchOnlyAccountStore { && lhs.xpub == rhs.xpub } - private static func accountRestorationOrder(_ lhs: WatchOnlyAccountRecord, _ rhs: WatchOnlyAccountRecord) -> Bool { - let lhsPriority = setupStatePriority(lhs.setupState) - let rhsPriority = setupStatePriority(rhs.setupState) - if lhsPriority != rhsPriority { return lhsPriority > rhsPriority } - if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt } - if lhs.walletIndex != rhs.walletIndex { return lhs.walletIndex < rhs.walletIndex } - if lhs.accountIndex != rhs.accountIndex { return lhs.accountIndex < rhs.accountIndex } - if lhs.addressType != rhs.addressType { return lhs.addressType < rhs.addressType } - if lhs.requestFingerprint != rhs.requestFingerprint { return lhs.requestFingerprint < rhs.requestFingerprint } - if lhs.id != rhs.id { return lhs.id.uuidString < rhs.id.uuidString } - if lhs.xpub != rhs.xpub { return lhs.xpub < rhs.xpub } - if lhs.name != rhs.name { return lhs.name < rhs.name } - return lhs.isTrackingEnabled && !rhs.isTrackingEnabled - } - - private static func setupStatePriority(_ state: WatchOnlyAccountSetupState) -> Int { - switch state { - case .active: return 3 - case .authorizing: return 2 - case .pendingDelivery: return 1 - } - } - private static func managementKey(_ account: WatchOnlyAccountRecord) -> String { "\(account.walletIndex):\(account.addressType):\(account.accountIndex)" } @@ -537,7 +435,8 @@ enum WatchOnlyAccountStore { private extension WatchOnlyAccountAllocationState { mutating func reconcileAccountIndexes(_ accounts: [WatchOnlyAccountRecord]) { - for (walletIndex, walletAccounts) in Dictionary(grouping: accounts, by: \WatchOnlyAccountRecord.walletIndex) { + let validWalletAccounts = accounts.filter { $0.walletIndex >= 0 } + for (walletIndex, walletAccounts) in Dictionary(grouping: validWalletAccounts, by: \WatchOnlyAccountRecord.walletIndex) { guard let accountIndex = walletAccounts.map(\.accountIndex).filter({ $0 > 0 && $0 <= UInt32(Int32.max) }).max() else { continue } @@ -545,6 +444,19 @@ private extension WatchOnlyAccountAllocationState { highestAccountIndexByWallet[walletKey] = max(highestAccountIndexByWallet[walletKey] ?? 0, accountIndex) } } + + mutating func raiseHighWaterMarksForPendingReservations() { + for (requestKey, accountIndex) in pendingAccountIndexByRequest { + guard let separatorIndex = requestKey.firstIndex(of: ":"), + let walletIndex = Int(requestKey[..= 0, + accountIndex > 0, + accountIndex <= UInt32(Int32.max) + else { continue } + let walletKey = String(walletIndex) + highestAccountIndexByWallet[walletKey] = max(highestAccountIndexByWallet[walletKey] ?? 0, accountIndex) + } + } } final class WatchOnlyAccountLifecycleCoordinator: @unchecked Sendable { @@ -629,7 +541,6 @@ final class WatchOnlyAccountManager { private let defaults: UserDefaults private let lifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator private let node: WatchOnlyAccountNodeHandling - private var preparationTasks: [String: Task<(WatchOnlyAccountRecord, Data), Error>] = [:] private var activeAuthorizationAttempt: WatchOnlyAccountAuthorizationAttempt? private var preservesAuthorizingStateOnFailure = false @@ -656,23 +567,11 @@ final class WatchOnlyAccountManager { func prepareUnsignedClaim(authUrl: String, name: String) async throws -> (WatchOnlyAccountRecord, Data) { let normalizedName = try Self.normalizedName(name) let fingerprint = Self.requestFingerprint(authUrl) - let walletIndex = node.currentWalletIndex - let taskKey = "\(walletIndex):\(fingerprint)" - - if let preparationTask = preparationTasks[taskKey] { - return try await preparationTask.value - } - - let preparationTask = Task { @MainActor in - try await self.prepareUnsignedClaim( - normalizedName: normalizedName, - fingerprint: fingerprint, - walletIndex: walletIndex - ) - } - preparationTasks[taskKey] = preparationTask - defer { preparationTasks[taskKey] = nil } - return try await preparationTask.value + return try await prepareUnsignedClaim( + normalizedName: normalizedName, + fingerprint: fingerprint, + walletIndex: node.currentWalletIndex + ) } private func prepareUnsignedClaim( diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 20589dffc..65858686a 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -31,64 +31,6 @@ func pubkyAuthDisplayPublicKey(_ publicKey: String?) -> String { return "\(rawKey.prefix(4))...\(rawKey.suffix(4))" } -typealias OrdinaryPubkyAuthApproval = (String, String, String) async throws -> Void -typealias CompanionPubkyAuthApproval = (String, Data, String) async throws -> Void - -@MainActor -func approvePubkyAuthRequest( - request: PubkyAuthRequest, - authUrl: String, - accountName: String, - secretKeyHex: String, - accountManager: WatchOnlyAccountManager? = nil, - ordinaryApproval: @escaping OrdinaryPubkyAuthApproval = { authUrl, capabilities, secretKeyHex in - try await PubkyService.approveAuth( - authUrl: authUrl, - expectedCapabilities: capabilities, - secretKeyHex: secretKeyHex - ) - }, - companionApproval: @escaping CompanionPubkyAuthApproval = { authUrl, unsignedPayload, secretKeyHex in - try await PubkyService.approveAuthWithCompanionClaim( - authUrl: authUrl, - unsignedPayload: unsignedPayload, - secretKeyHex: secretKeyHex - ) - } -) async throws { - let accountManager = accountManager ?? .shared - if request.bitkitClaim == .watchOnlyAccountV1 { - let preparedClaim = try await accountManager.prepareUnsignedClaim(authUrl: authUrl, name: accountName) - let authorizationAttempt = try accountManager.acquireSetupAuthorizationAttempt(id: preparedClaim.0.id) - defer { accountManager.finishSetupAuthorizationAttempt(authorizationAttempt) } - do { - try await accountManager.beginSetupAuthorization(attempt: authorizationAttempt) - } catch { - do { - try await accountManager.cancelSetupAuthorization(attempt: authorizationAttempt) - } catch let cleanupError { - Logger.error("Failed to unload incomplete watch-only account: \(cleanupError)", context: "PubkyAuthApprovalSheet") - } - throw error - } - do { - try await companionApproval(authUrl, preparedClaim.1, secretKeyHex) - } catch { - if !PubkyService.didDeliverCompanionClaim(error: error) { - do { - try await accountManager.cancelSetupAuthorization(attempt: authorizationAttempt) - } catch let cleanupError { - Logger.error("Failed to unload incomplete watch-only account: \(cleanupError)", context: "PubkyAuthApprovalSheet") - } - } - throw error - } - try await accountManager.markSetupActive(attempt: authorizationAttempt) - } else { - try await ordinaryApproval(authUrl, request.capabilities, secretKeyHex) - } -} - struct PubkyAuthApprovalConfig { let authUrl: String let request: PubkyAuthRequest @@ -204,7 +146,7 @@ struct PubkyAuthApprovalSheet: View { } } - // MARK: - Watch-Only Consent State (Screen 1) + // MARK: - Watch-Only Consent private var watchOnlyConsentContent: some View { GeometryReader { geometry in @@ -253,7 +195,7 @@ struct PubkyAuthApprovalSheet: View { .accessibilityIdentifier("PubkyAuthWatchOnlyConsent") } - // MARK: - Authorize State (Screen 2) + // MARK: - Authorization private var authorizeContent: some View { VStack(alignment: .leading, spacing: 0) { @@ -273,7 +215,7 @@ struct PubkyAuthApprovalSheet: View { } } - // MARK: - Authorizing State (Screen 3) + // MARK: - Authorization Progress private var authorizingContent: some View { VStack(alignment: .leading, spacing: 0) { @@ -285,7 +227,7 @@ struct PubkyAuthApprovalSheet: View { } } - // MARK: - Success State (Screen 4) + // MARK: - Success private var successContent: some View { VStack(alignment: .leading, spacing: 0) { @@ -469,7 +411,7 @@ struct PubkyAuthApprovalSheet: View { return } - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: config.request, authUrl: config.authUrl, accountName: effectiveWatchOnlyAccountName, diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index c523da609..e0b549729 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -84,7 +84,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { var companionApprovalCount = 0 do { - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -118,7 +118,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let node = ApprovalFakeWatchOnlyAccountNode() let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -133,46 +133,12 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { } @MainActor - func testDuplicateConfirmationRunsOneCompanionApprovalLifecycle() async throws { - let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" - let request = try PubkyAuthRequest.parse(url: authUrl) - let node = ApprovalFakeWatchOnlyAccountNode() - let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) - let companionApprovalGate = ApprovalCompanionGate() - let harness = ApprovalSingleFlightHarness( - request: request, - authUrl: authUrl, - manager: manager, - companionApprovalGate: companionApprovalGate - ) - - let firstConfirmation = Task { @MainActor in - try await harness.confirm() - } - try await companionApprovalGate.waitUntilFirstApprovalStarts() - - do { - try await harness.confirm() - } catch { - await companionApprovalGate.releaseFirstApproval() - _ = try? await firstConfirmation.value - throw error - } - - let companionApprovalCount = await companionApprovalGate.approvalCount - XCTAssertEqual(companionApprovalCount, 1) - XCTAssertEqual(node.trackingChanges, [true]) + func testApprovalStateBeginsAuthorizationOnlyOnce() { + var state = PubkyAuthApprovalSheet.ApprovalState.authorize - await companionApprovalGate.releaseFirstApproval() - try await firstConfirmation.value - - XCTAssertEqual(manager.accounts.count, 1) - XCTAssertEqual(manager.accounts.first?.setupState, .active) - XCTAssertEqual(node.trackingChanges, [true]) + XCTAssertTrue(state.beginAuthorization()) + XCTAssertEqual(state, .authorizing) + XCTAssertFalse(state.beginAuthorization()) } @MainActor @@ -189,7 +155,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) let companionApprovalGate = ApprovalCompanionGate() let firstApproval = Task { @MainActor in - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: firstRequest, authUrl: firstAuthUrl, accountName: "First account", @@ -202,7 +168,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { for (request, authUrl) in [(secondRequest, secondAuthUrl), (firstRequest, firstAuthUrl)] { do { - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Replacement account", @@ -223,7 +189,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { await companionApprovalGate.releaseFirstApproval() try await firstApproval.value - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: secondRequest, authUrl: secondAuthUrl, accountName: "Second account", @@ -248,7 +214,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) await XCTAssertThrowsErrorAsync { - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -277,7 +243,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) await XCTAssertThrowsErrorAsync { - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -290,7 +256,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { } await XCTAssertThrowsErrorAsync { - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -319,7 +285,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { var companionApprovalCount = 0 await XCTAssertThrowsErrorAsync { - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -349,7 +315,7 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { let companionApprovalGate = ApprovalCompanionGate() let approval = Task { @MainActor in - try await approvePubkyAuthRequest( + try await PubkyService.approveAuthRequest( request: request, authUrl: authUrl, accountName: "Creator store", @@ -382,48 +348,6 @@ private enum ApprovalFakeError: Error { case trackingPreparationFailed } -@MainActor -private final class ApprovalSingleFlightHarness { - private var state: PubkyAuthApprovalSheet.ApprovalState = .authorize - private let request: Bitkit.PubkyAuthRequest - private let authUrl: String - private let manager: Bitkit.WatchOnlyAccountManager - private let companionApprovalGate: ApprovalCompanionGate - - init( - request: Bitkit.PubkyAuthRequest, - authUrl: String, - manager: Bitkit.WatchOnlyAccountManager, - companionApprovalGate: ApprovalCompanionGate - ) { - self.request = request - self.authUrl = authUrl - self.manager = manager - self.companionApprovalGate = companionApprovalGate - } - - func confirm() async throws { - guard state.beginAuthorization() else { return } - - do { - try await approvePubkyAuthRequest( - request: request, - authUrl: authUrl, - accountName: "Creator store", - secretKeyHex: "secret", - accountManager: manager, - companionApproval: { [companionApprovalGate] _, _, _ in - await companionApprovalGate.approve() - } - ) - state = .success - } catch { - state = .authorize - throw error - } - } -} - private actor ApprovalCompanionGate { private var count = 0 private var firstApprovalContinuation: CheckedContinuation? diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index e52df93c0..5d07ed181 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -30,40 +30,29 @@ final class WatchOnlyAccountServiceTests: XCTestCase { } } - func testRestoreDoesNotLowerAllocatorHighWaterMark() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "first", defaults: defaults), 1) - try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "first", defaults: defaults) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "second", defaults: defaults), 2) - try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "second", defaults: defaults) - - let restored = makeRecord(accountIndex: 1, xpub: base58CheckEncode(Data(repeating: 1, count: 78))) - try WatchOnlyAccountStore.restore([restored], defaults: defaults) - - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex(walletIndex: 0, requestFingerprint: "next", defaults: defaults), 3) - } - - func testRestoreWithoutAllocatorClearsPendingReservationAndPreservesHighWaterMark() throws { + func testRestorePreservesAllocatorHighWaterMarkWhileClearingUnrestoredReservation() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( walletIndex: 0, - requestFingerprint: "stale-pending", + requestFingerprint: "first", defaults: defaults ), 1) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "second", + defaults: defaults + ), 2) try WatchOnlyAccountStore.restore([], defaults: defaults) XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( walletIndex: 0, - requestFingerprint: "stale-pending", + requestFingerprint: "first", defaults: defaults - ), 2) + ), 3) } func testBackupRestoresPendingReservationAndHighWaterMark() throws { @@ -101,7 +90,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ), 2) } - func testRestorePreservesExactLocalPendingReservationForRetry() throws { + func testRestoreKeepsMatchingLocalPendingReservation() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } @@ -124,241 +113,9 @@ final class WatchOnlyAccountServiceTests: XCTestCase { requestFingerprint: "pending", defaults: defaults ), 1) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "next", - defaults: defaults - ), 2) } - func testRestoreRejectsDivergentReservationForLocalPendingRequest() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - for index in 1 ... 6 { - let requestFingerprint = "completed-\(index)" - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: requestFingerprint, - defaults: defaults - ), UInt32(index)) - try WatchOnlyAccountStore.completeAllocation( - walletIndex: 0, - requestFingerprint: requestFingerprint, - defaults: defaults - ) - } - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "pending", - defaults: defaults - ), 7) - - try WatchOnlyAccountStore.restore( - [], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 7], - pendingAccountIndexByRequest: ["0:pending": 7] - ), - defaults: defaults - ) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "pending", - defaults: defaults - ), 7) - - try WatchOnlyAccountStore.restore( - [], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 0], - pendingAccountIndexByRequest: ["0:pending": 8] - ), - defaults: defaults - ) - - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:pending"]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "pending", - defaults: defaults - ), 9) - } - - func testRestoreBurnsConflictingPendingReservationSlot() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - try WatchOnlyAccountStore.restore( - [], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 0], - pendingAccountIndexByRequest: [ - "0:first": 1, - "0:second": 1, - ] - ), - defaults: defaults - ) - - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertTrue(snapshot.allocationState.pendingAccountIndexByRequest.isEmpty) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "next", - defaults: defaults - ), 2) - } - - func testRestoreSanitizesDuplicateAccountOwners() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let sharedId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) - let pendingDuplicateId = makeRecord( - accountIndex: 1, - xpub: xpub, - id: sharedId, - requestFingerprint: "pending-duplicate-id" - ) - let activeDuplicateId = makeRecord( - accountIndex: 2, - xpub: xpub, - setupState: .active, - id: sharedId, - requestFingerprint: "completed-request" - ) - let pendingDuplicateSlot = makeRecord( - accountIndex: 3, - xpub: xpub, - requestFingerprint: "pending-duplicate-slot" - ) - let authorizingDuplicateSlot = makeRecord( - accountIndex: 3, - xpub: xpub, - setupState: .authorizing, - requestFingerprint: "authorizing-duplicate-slot" - ) - let pendingDuplicateRequest = makeRecord( - accountIndex: 4, - xpub: xpub, - requestFingerprint: "duplicate-incomplete-request" - ) - let authorizingDuplicateRequest = makeRecord( - accountIndex: 5, - xpub: xpub, - setupState: .authorizing, - requestFingerprint: "duplicate-incomplete-request" - ) - let repeatedCompletedRequest = makeRecord( - accountIndex: 6, - xpub: xpub, - setupState: .active, - requestFingerprint: "completed-request" - ) - - try WatchOnlyAccountStore.restore( - [ - pendingDuplicateId, - activeDuplicateId, - pendingDuplicateSlot, - authorizingDuplicateSlot, - pendingDuplicateRequest, - authorizingDuplicateRequest, - repeatedCompletedRequest, - ], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 6], - pendingAccountIndexByRequest: [ - "0:pending-duplicate-id": 1, - "0:duplicate-incomplete-request": 4, - ] - ), - defaults: defaults - ) - - let restored = try WatchOnlyAccountStore.load(defaults: defaults) - XCTAssertEqual( - Set(restored.map(\.id)), - Set([activeDuplicateId.id, authorizingDuplicateSlot.id, authorizingDuplicateRequest.id, repeatedCompletedRequest.id]) - ) - XCTAssertEqual(restored.map(\.accountIndex), [2, 3, 5, 6]) - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:pending-duplicate-id"]) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:duplicate-incomplete-request"]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "pending-duplicate-id", - defaults: defaults - ), 7) - } - - func testRestoreRaisesHighWaterForDiscardedDuplicateAccount() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let sharedId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let retained = makeRecord( - accountIndex: 1, - xpub: xpub, - setupState: .active, - id: sharedId, - requestFingerprint: "retained" - ) - let discarded = makeRecord( - accountIndex: 7, - xpub: xpub, - setupState: .active, - id: sharedId, - requestFingerprint: "discarded" - ) - - try WatchOnlyAccountStore.restore([discarded, retained], defaults: defaults) - - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [retained]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "next", - defaults: defaults - ), 8) - } - - func testRestoreRejectsReservationAtDiscardedAccountSlot() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let sharedId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let retained = makeRecord(accountIndex: 1, xpub: xpub, setupState: .active, id: sharedId) - let discarded = makeRecord(accountIndex: 7, xpub: xpub, setupState: .active, id: sharedId) - - try WatchOnlyAccountStore.restore( - [discarded, retained], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 7], - pendingAccountIndexByRequest: ["0:retry": 7] - ), - defaults: defaults - ) - - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:retry"]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "retry", - defaults: defaults - ), 8) - } - - func testRestoreNormalizesTrackingForIncompleteAccounts() throws { + func testRestoreSanitizesAccountsAndNormalizesIncompleteTracking() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } @@ -377,54 +134,42 @@ final class WatchOnlyAccountServiceTests: XCTestCase { setupState: .authorizing, requestFingerprint: "authorizing" ) - let disabledActive = makeRecord( + let activeDisabled = makeRecord( accountIndex: 3, xpub: xpub, isTrackingEnabled: false, setupState: .active, requestFingerprint: "active" ) + let duplicateSlot = makeRecord( + accountIndex: 1, + xpub: xpub, + setupState: .active, + requestFingerprint: "duplicate" + ) + let invalid = makeRecord(accountIndex: 4, xpub: "invalid", requestFingerprint: "invalid") - try WatchOnlyAccountStore.restore([pending, authorizing, disabledActive], defaults: defaults) + try WatchOnlyAccountStore.restore( + [pending, authorizing, activeDisabled, duplicateSlot, invalid], + defaults: defaults + ) - let accounts = try WatchOnlyAccountStore.load(defaults: defaults) - XCTAssertEqual(accounts.first(where: { $0.id == pending.id })?.isTrackingEnabled, false) - XCTAssertEqual(accounts.first(where: { $0.id == authorizing.id })?.isTrackingEnabled, true) - XCTAssertEqual(accounts.first(where: { $0.id == disabledActive.id })?.isTrackingEnabled, false) + let restored = try WatchOnlyAccountStore.load(defaults: defaults) + XCTAssertEqual(restored.map(\.id), [pending.id, authorizing.id, activeDisabled.id]) + XCTAssertEqual(restored.map(\.isTrackingEnabled), [false, true, false]) } - func testRestoreDropsUnusableAccountsAndBurnsTheirIndexes() throws { + func testRestoreBurnsIndexesFromDiscardedAccounts() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let valid = makeRecord(accountIndex: 1, xpub: xpub, setupState: .active) - let invalidAddressType = makeRecord( - accountIndex: 7, - xpub: xpub, - addressType: "legacy", - setupState: .active - ) - let invalidXpub = makeRecord(accountIndex: 8, xpub: "not-an-xpub", setupState: .authorizing) - let accountZero = makeRecord(accountIndex: 0, xpub: xpub, setupState: .active) - let outOfRange = makeRecord(accountIndex: .max, xpub: xpub, setupState: .active) - try WatchOnlyAccountStore.restore( - [outOfRange, invalidXpub, accountZero, invalidAddressType, valid], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": .max], - pendingAccountIndexByRequest: ["0:out-of-range": .max] - ), + [makeRecord(accountIndex: 8, xpub: "invalid", requestFingerprint: "invalid")], defaults: defaults ) - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [valid]) - XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults), [valid]) - XCTAssertNil( - try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - .allocationState.pendingAccountIndexByRequest["0:out-of-range"] - ) + XCTAssertTrue(try WatchOnlyAccountStore.load(defaults: defaults).isEmpty) XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( walletIndex: 0, requestFingerprint: "next", @@ -432,393 +177,56 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ), 9) } - func testRestorePreservesAuthorizingAccountsOverBackupConflicts() throws { + func testRestorePreservesAuthorizingAccountOverBackupState() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let authorizingById = makeRecord( + let local = makeRecord( accountIndex: 1, xpub: xpub, setupState: .authorizing, - requestFingerprint: "authorizing-by-id" - ) - let authorizingByKey = makeRecord( - accountIndex: 2, - xpub: xpub, - setupState: .authorizing, - requestFingerprint: "authorizing-by-key" - ) - try WatchOnlyAccountStore.save([authorizingById, authorizingByKey], defaults: defaults) - - let conflictingId = makeRecord( - accountIndex: 9, - xpub: xpub, - setupState: .active, - id: authorizingById.id, - requestFingerprint: "backup-id-conflict" - ) - let conflictingKey = makeRecord( - accountIndex: authorizingByKey.accountIndex, - xpub: xpub, - setupState: .active, - requestFingerprint: "backup-key-conflict" + requestFingerprint: "request" ) let restored = makeRecord( - accountIndex: 7, - xpub: xpub, - setupState: .active, - requestFingerprint: "restored" - ) - try WatchOnlyAccountStore.restore( - [conflictingId, conflictingKey, restored], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 9], - pendingAccountIndexByRequest: ["0:restored": restored.accountIndex] - ), - defaults: defaults - ) - - let accounts = try WatchOnlyAccountStore.load(defaults: defaults) - XCTAssertEqual(Set(accounts.map(\.id)), Set([authorizingById.id, authorizingByKey.id, restored.id])) - XCTAssertEqual(accounts.first(where: { $0.id == authorizingById.id }), authorizingById) - XCTAssertEqual(accounts.first(where: { $0.id == authorizingByKey.id }), authorizingByKey) - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:authorizing-by-id"], 1) - XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:authorizing-by-key"], 2) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:restored"]) - - _ = try WatchOnlyAccountStore.markSetupActive(id: authorizingById.id, defaults: defaults) - XCTAssertEqual( - try WatchOnlyAccountStore.load(defaults: defaults).first(where: { $0.id == authorizingById.id })?.setupState, - .active - ) - } - - func testRestorePreservesLocalOwnerWhenBackupReusesItsSlot() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let local = makeRecord( - accountIndex: 5, - xpub: xpub, - setupState: .active, - requestFingerprint: "local-owner" - ) - let conflictingBackup = makeRecord( - accountIndex: 5, + accountIndex: 1, xpub: xpub, setupState: .active, - requestFingerprint: "restored-owner" + id: local.id, + requestFingerprint: "request" ) try WatchOnlyAccountStore.save([local], defaults: defaults) - try WatchOnlyAccountStore.restore([conflictingBackup], defaults: defaults) + try WatchOnlyAccountStore.restore([restored], defaults: defaults) XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) - XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [local]) - } - - func testRestorePrefersDeliveredOwnerOverLocalPendingSlot() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let localPending = makeRecord( - accountIndex: 5, - xpub: xpub, - isTrackingEnabled: false, - requestFingerprint: "local-pending" - ) - let restoredActive = makeRecord( - accountIndex: 5, - xpub: xpub, - setupState: .active, - requestFingerprint: "restored-active" - ) - try WatchOnlyAccountStore.save([localPending], defaults: defaults) - - try WatchOnlyAccountStore.restore([restoredActive], defaults: defaults) - - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [restoredActive]) - XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults), [restoredActive]) - XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [restoredActive]) - } - - func testRestoreKeepsTrackingEnabledAcrossDeliveredOwnerConflict() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let localDisabled = makeRecord( - accountIndex: 5, - xpub: xpub, - isTrackingEnabled: false, - setupState: .active, - requestFingerprint: "local-active" - ) - let restoredEnabled = makeRecord( - accountIndex: 5, - xpub: xpub, - setupState: .active, - requestFingerprint: "restored-active" - ) - try WatchOnlyAccountStore.save([localDisabled], defaults: defaults) - - try WatchOnlyAccountStore.restore([restoredEnabled], defaults: defaults) - - let account = try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first) - XCTAssertEqual(account.id, localDisabled.id) - XCTAssertTrue(account.isTrackingEnabled) - XCTAssertEqual(try WatchOnlyAccountStore.enabledAccounts(for: 0, defaults: defaults), [account]) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:request"], 1) } - func testRestoreProtectsOwnerAwaitingUnloadFromConflictingSlot() throws { + func testRestorePreservesLocalOwnerWhenBackupReusesItsSlot() throws { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) let local = makeRecord( - accountIndex: 5, - xpub: xpub, + accountIndex: 1, + xpub: base58CheckEncode(Data(repeating: 1, count: 78)), setupState: .active, - requestFingerprint: "local-owner" + requestFingerprint: "local" ) let conflictingBackup = makeRecord( - accountIndex: 5, - xpub: xpub, + accountIndex: 1, + xpub: base58CheckEncode(Data(repeating: 2, count: 78)), setupState: .active, - requestFingerprint: "restored-owner" + requestFingerprint: "restored" ) try WatchOnlyAccountStore.save([local], defaults: defaults) - try WatchOnlyAccountStore.restore([], defaults: defaults) try WatchOnlyAccountStore.restore([conflictingBackup], defaults: defaults) XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) - XCTAssertEqual(try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, [local]) - } - - func testRestoreDoesNotRegressActiveLocalOwnerToIncomplete() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let local = makeRecord( - accountIndex: 5, - xpub: xpub, - setupState: .active, - requestFingerprint: "same-owner" - ) - let incompleteBackup = makeRecord( - accountIndex: 5, - xpub: xpub, - isTrackingEnabled: false, - requestFingerprint: local.requestFingerprint - ) - try WatchOnlyAccountStore.save([local], defaults: defaults) - - try WatchOnlyAccountStore.restore([incompleteBackup], defaults: defaults) - - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) - } - - func testRestoreUsesSameCanonicalIncompleteOwnerRegardlessOfInputOrder() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let lowerIndexId = try XCTUnwrap(UUID(uuidString: "ffffffff-ffff-ffff-ffff-ffffffffffff")) - let higherIndexId = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) - let lowerIndex = makeRecord( - accountIndex: 3, - xpub: xpub, - isTrackingEnabled: false, - id: lowerIndexId, - requestFingerprint: "shared-request" - ) - let higherIndex = makeRecord( - accountIndex: 4, - xpub: xpub, - isTrackingEnabled: false, - id: higherIndexId, - requestFingerprint: lowerIndex.requestFingerprint - ) - - try WatchOnlyAccountStore.restore([higherIndex, lowerIndex], defaults: defaults) - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [lowerIndex]) - - try WatchOnlyAccountStore.restore([lowerIndex, higherIndex], defaults: defaults) - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [lowerIndex]) - } - - func testRestoreDropsPendingReservationThatCollidesWithAuthorizingRequest() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) - let authorizing = makeRecord( - accountIndex: 5, - xpub: xpub, - setupState: .authorizing, - requestFingerprint: "request-a" - ) - try WatchOnlyAccountStore.restore( - [authorizing], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 5], - pendingAccountIndexByRequest: ["0:request-a": 5] - ), - defaults: defaults - ) - - let conflictingBackupAccount = makeRecord( - accountIndex: 5, - xpub: xpub, - requestFingerprint: "request-b" - ) - try WatchOnlyAccountStore.restore( - [conflictingBackupAccount], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 5], - pendingAccountIndexByRequest: ["0:request-b": 5] - ), - defaults: defaults - ) - - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertEqual(snapshot.accounts, [authorizing]) - XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:request-a"], 5) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:request-b"]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "request-b", - defaults: defaults - ), 6) - } - - func testRestoreDropsUnboundPendingReservationBelowLocalHighWaterMark() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "first", - defaults: defaults - ), 1) - try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "first", defaults: defaults) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "second", - defaults: defaults - ), 2) - try WatchOnlyAccountStore.completeAllocation(walletIndex: 0, requestFingerprint: "second", defaults: defaults) - - try WatchOnlyAccountStore.restore( - [], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 1], - pendingAccountIndexByRequest: ["0:restored-request": 1] - ), - defaults: defaults - ) - - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:restored-request"]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "restored-request", - defaults: defaults - ), 3) - } - - func testRestoreDropsPendingReservationForAccountAwaitingUnload() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let accountAwaitingUnload = makeRecord( - accountIndex: 5, - xpub: base58CheckEncode(Data(repeating: 1, count: 78)), - setupState: .active, - requestFingerprint: "current-request" - ) - try WatchOnlyAccountStore.save([accountAwaitingUnload], defaults: defaults) - - try WatchOnlyAccountStore.restore( - [], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 5], - pendingAccountIndexByRequest: ["0:restored-request": 5] - ), - defaults: defaults - ) - - let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) - XCTAssertNil(snapshot.allocationState.pendingAccountIndexByRequest["0:restored-request"]) - XCTAssertEqual( - try WatchOnlyAccountStore.reconciliationSnapshot(defaults: defaults).managedAccounts, - [accountAwaitingUnload] - ) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "restored-request", - defaults: defaults - ), 6) - } - - func testCorruptedStateFailsClosedWithoutResettingAllocator() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - let corruptedData = Data("not-json".utf8) - defaults.set(corruptedData, forKey: WatchOnlyAccountStore.dataKey) - - XCTAssertThrowsError(try WatchOnlyAccountStore.load(defaults: defaults)) - XCTAssertThrowsError(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "request", - defaults: defaults - )) - XCTAssertEqual(defaults.data(forKey: WatchOnlyAccountStore.dataKey), corruptedData) - } - - func testRestoreRepairsCorruptedLocalStateFromValidBackup() throws { - let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(Data("not-json".utf8), forKey: WatchOnlyAccountStore.dataKey) - let restored = makeRecord( - accountIndex: 4, - xpub: base58CheckEncode(Data(repeating: 1, count: 78)), - setupState: .active - ) - - try WatchOnlyAccountStore.restore( - [restored], - allocationState: WatchOnlyAccountAllocationState( - highestAccountIndexByWallet: ["0": 6], - pendingAccountIndexByRequest: ["0:pending": 6] - ), - defaults: defaults - ) - - XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [restored]) - XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( - walletIndex: 0, - requestFingerprint: "pending", - defaults: defaults - ), 6) } func testReconciliationClearsPendingUnloadsOnlyForCurrentWallet() throws { diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md index f857a101a..6aa08e764 100644 --- a/Docs/watch-only-account-claim-v1.md +++ b/Docs/watch-only-account-claim-v1.md @@ -47,5 +47,5 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. - Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. - Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. -- Startup reconciliation restores runtime tracking and the pre-revealed range. Reconciliation is serialized with setup, restore, and tracking mutations so a stale snapshot cannot undo the latest account state. When a backup replaces the account list, references to removed accounts remain locally until reconciliation successfully unloads them. A transient reconciliation failure does not leave the node in a failed-but-running state; the next app-driven wallet sync retries reconciliation. +- On startup and before app-driven sync, Bitkit reconciles persisted account state with LDK and restores the pre-revealed range. Accounts removed by a backup remain scheduled for unload until reconciliation succeeds, allowing transient failures to retry safely. - Account metadata and monotonic allocation state are included in the existing encrypted wallet backup and use the same JSON field names on iOS and Android. From 6c0cc5be02bb376dc9bdbfc4dde360e1df6f5b13 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 11:14:13 +0200 Subject: [PATCH 11/16] refactor: simplify watch-only account naming --- .../Localization/en.lproj/Localizable.strings | 4 ---- Bitkit/Services/PubkyService.swift | 2 +- .../PubkyAuthApproval/PubkyAuthApprovalSheet.swift | 13 ++----------- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 2be3de2e6..76add3504 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -664,13 +664,9 @@ "pubky_auth__description_prefix" = "A service is requesting permission to access and edit your "; "pubky_auth__description_suffix" = " data."; "pubky_auth__requested_permissions" = "REQUESTED PERMISSIONS"; -"pubky_auth__watch_only_account_title" = "WATCH-ONLY BITCOIN ACCOUNT"; -"pubky_auth__watch_only_account_description" = "Share a watch-only Bitcoin account. This lets the service generate receive addresses and view this account's transactions and balance, but not spend funds."; "pubky_auth__watch_only_account_default_name" = "{service} account"; "pubky_auth__watch_only_account_fallback_name" = "Paykit server account"; "pubky_auth__watch_only_account_name_error" = "Enter an account name between 1 and 64 characters."; -"pubky_auth__watch_only_account_name_label" = "ACCOUNT NAME"; -"pubky_auth__watch_only_account_name_placeholder" = "Name this account"; "pubky_auth__watch_only_intro_approve" = "Approve"; "pubky_auth__watch_only_intro_description" = "To earn, you need to share a watch-only Bitcoin account with Paykit. It can view sales activity, but cannot spend funds."; "pubky_auth__watch_only_intro_nav_title" = "Earn"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 349909572..c32fc0cc9 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -110,7 +110,7 @@ enum PubkyService { static func didDeliverCompanionClaim(error: Error) -> Bool { guard let approvalError = error as? Paykit.PubkyAuthCompanionClaimApprovalError else { return false } - // Paykit uses AuthorizationFailure exclusively after companion delivery; every other variant is pre-delivery. + // Paykit documents AuthorizationFailure as the post-delivery case; unknown errors do not imply delivery. if case .AuthorizationFailure = approvalError { return true } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 65858686a..ae00058d7 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -55,7 +55,6 @@ struct PubkyAuthApprovalSheet: View { @State private var state: ApprovalState @State private var isShowingAuthCheck = false - @State private var watchOnlyAccountName = "" enum ApprovalState: Equatable { case watchOnlyConsent @@ -138,12 +137,6 @@ struct PubkyAuthApprovalSheet: View { } ) } - .task { - guard config.request.bitkitClaim != nil, watchOnlyAccountName.isEmpty else { return } - watchOnlyAccountName = config.request.serviceNames.first.map { - t("pubky_auth__watch_only_account_default_name", variables: ["service": $0]) - } ?? t("pubky_auth__watch_only_account_fallback_name") - } } // MARK: - Watch-Only Consent @@ -414,7 +407,7 @@ struct PubkyAuthApprovalSheet: View { try await PubkyService.approveAuthRequest( request: config.request, authUrl: config.authUrl, - accountName: effectiveWatchOnlyAccountName, + accountName: watchOnlyAccountName, secretKeyHex: secretKey ) @@ -426,9 +419,7 @@ struct PubkyAuthApprovalSheet: View { } } - private var effectiveWatchOnlyAccountName: String { - let trimmedName = watchOnlyAccountName.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmedName.isEmpty else { return trimmedName } + private var watchOnlyAccountName: String { return config.request.serviceNames.first.map { t("pubky_auth__watch_only_account_default_name", variables: ["service": $0]) } ?? t("pubky_auth__watch_only_account_fallback_name") From 5da39570cd01d553c2837fa91b4e856dadd7ed03 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 13:13:17 +0200 Subject: [PATCH 12/16] refactor: use core xpub serialization --- Bitkit.xcodeproj/project.pbxproj | 19 +------ .../xcshareddata/swiftpm/Package.resolved | 24 ++------- Bitkit/Services/WatchOnlyAccountService.swift | 18 ++++--- BitkitTests/PubkyAuthApprovalSheetTests.swift | 11 ++-- .../WatchOnlyAccountServiceTests.swift | 50 +++++++++---------- 5 files changed, 45 insertions(+), 77 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index eafbf77b0..16f627987 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 182817C12F59A7F10055A441 /* Paykit in Frameworks */ = {isa = PBXBuildFile; productRef = 182817C02F59A7F10055A441 /* Paykit */; }; - 18B58A012F7A000100B58A01 /* Base58Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 18B58A032F7A000100B58A01 /* Base58Swift */; }; 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D76260E4C9C4A53B1E4A001 /* CoreBluetooth.framework */; }; @@ -259,7 +258,6 @@ buildActionMask = 2147483647; files = ( 182817C12F59A7F10055A441 /* Paykit in Frameworks */, - 18B58A012F7A000100B58A01 /* Base58Swift in Frameworks */, 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, @@ -401,7 +399,6 @@ 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, 182817C02F59A7F10055A441 /* Paykit */, - 18B58A032F7A000100B58A01 /* Base58Swift */, ); productName = Bitkit; productReference = 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */; @@ -512,7 +509,6 @@ 4AAB08C82E1FE77600BA63DF /* XCRemoteSwiftPackageReference "lottie-ios" */, 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */, 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */, - 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */, ); productRefGroup = 96FE1F622C2DE6AA006D0C8B /* Products */; projectDirPath = ""; @@ -1180,14 +1176,6 @@ version = "0.1.0-rc36"; }; }; - 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/keefertaylor/Base58Swift"; - requirement = { - kind = exactVersion; - version = 2.1.14; - }; - }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; @@ -1217,7 +1205,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.4.0; + version = 0.4.2; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { @@ -1244,11 +1232,6 @@ package = 182817BF2F59A7F10055A441 /* XCRemoteSwiftPackageReference "paykit-rs" */; productName = Paykit; }; - 18B58A032F7A000100B58A01 /* Base58Swift */ = { - isa = XCSwiftPackageProductDependency; - package = 18B58A022F7A000100B58A01 /* XCRemoteSwiftPackageReference "Base58Swift" */; - productName = Base58Swift; - }; 18D65DFF2EB964B500252335 /* VssRustClientFfi */ = { isa = XCSwiftPackageProductDependency; package = 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7406f9a18..9578ea1eb 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,31 +1,13 @@ { - "originHash" : "51f054936b33d846460247543fc66f705845d9ac042d7a45c72b167d6171bac9", + "originHash" : "746cacd44d171f1c992b592ea1224d0562117bffd2907a54dee9d441395ac6b4", "pins" : [ - { - "identity" : "base58swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/keefertaylor/Base58Swift", - "state" : { - "revision" : "1c13ea6b07f1584660526f8bde3d4cc150e91acd", - "version" : "2.1.14" - } - }, - { - "identity" : "bigint", - "kind" : "remoteSourceControl", - "location" : "https://github.com/attaswift/BigInt.git", - "state" : { - "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", - "version" : "5.7.0" - } - }, { "identity" : "bitkit-core", "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "4979f4d23aaaeec11a09b77c8117a0afdaad4317", - "version" : "0.4.0" + "revision" : "dd99b46d4b849a7716b45664136c4d1ce5e5905e", + "version" : "0.4.2" } }, { diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 687b4e754..0d13db020 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -1,4 +1,4 @@ -import Base58Swift +import BitkitCore import Combine import CryptoKit import Foundation @@ -905,12 +905,18 @@ enum WatchOnlyAccountClaimCodec { } static func serializedXpub(_ xpub: String) throws -> Data { - guard xpub.count > 4, - let decoded = Base58.base58CheckDecode(xpub), - decoded.count == serializedXpubLength - else { + guard xpub.count > 4 else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + + do { + let serialized = try BitkitCore.serializedExtendedPubkey(xpub: xpub) + guard serialized.count == serializedXpubLength else { + throw WatchOnlyAccountError.invalidExtendedPublicKey + } + return serialized + } catch { throw WatchOnlyAccountError.invalidExtendedPublicKey } - return Data(decoded) } } diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index e0b549729..ecd5c76bd 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -1,9 +1,12 @@ -import Base58Swift @testable import Bitkit import LDKNode import Paykit import XCTest +private let approvalTestXpub = + "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" + final class PubkyAuthApprovalSheetTests: XCTestCase { func testAuthDisplayPublicKeyOmitsPubkyPrefix() { XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg") @@ -390,7 +393,7 @@ private final class ApprovalFakeWatchOnlyAccountNode: Bitkit.WatchOnlyAccountNod private(set) var trackingChanges: [Bool] = [] func exportWatchOnlyAccountXpub(accountIndex _: UInt32, addressType _: LDKNode.AddressType) async throws -> String { - base58CheckEncode(Data((0 ..< Bitkit.WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8($0 + 1) })) + approvalTestXpub } func setWatchOnlyAccountTracking( @@ -413,10 +416,6 @@ private final class ApprovalFakeWatchOnlyAccountNode: Bitkit.WatchOnlyAccountNod records _: [Bitkit.WatchOnlyAccountRecord], managedRecords _: [Bitkit.WatchOnlyAccountRecord] ) async throws {} - - private func base58CheckEncode(_ payload: Data) -> String { - Base58.base58CheckEncode([UInt8](payload)) - } } private func XCTAssertThrowsErrorAsync( diff --git a/BitkitTests/WatchOnlyAccountServiceTests.swift b/BitkitTests/WatchOnlyAccountServiceTests.swift index 5d07ed181..3ab0460be 100644 --- a/BitkitTests/WatchOnlyAccountServiceTests.swift +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -1,12 +1,21 @@ -import Base58Swift @testable import Bitkit import LDKNode import XCTest +private let testXpub = + "tpubDDWohsp5dx2iMJ9N7iHbgAEDhH4BJB9NWW1fEW3yA3AFNDREmpzteCXNqppMLUmKFY5q5e3" + + "PXtS5CuqWCQbYcGhpPqYAgQSYdwknW9J6sQv" +private let alternateTestXpub = + "tpubDCgMbrEACV32r3jqiWn685Ni6Z8vkPtVn73Pv5ZvyXkwh4iFbrpcrXXPvtJhiCvHvRvZY6dXU" + + "gKt8aZEPpo4tRpHkNC7jR9B7JVZo8kFxCz" +private let testSerializedXpubHex = + "043587cf03caafd489800000004b5fcc4a5fe210d9fba6616b4db1d025237dd7f035101f11f562401bc7104699" + + "02e0bf22b51a6a49e0b149b995670d0ed9bb1fd99417748bacefba88fae655572d" + final class WatchOnlyAccountServiceTests: XCTestCase { func testUnsignedClaimContainsExactAccountMetadata() throws { - let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8($0 + 1) }) - let record = makeRecord(accountIndex: 42, xpub: base58CheckEncode(rawXpub)) + let rawXpub = testSerializedXpubHex.hexaData + let record = makeRecord(accountIndex: 42, xpub: testXpub) let payload = try WatchOnlyAccountClaimCodec.encode(record: record) @@ -21,9 +30,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { } func testUnsignedClaimRejectsInvalidBase58CheckChecksum() throws { - let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8($0 + 1) }) - let validXpub = base58CheckEncode(rawXpub) - let invalidXpub = String(validXpub.dropLast()) + (validXpub.last == "1" ? "2" : "1") + let invalidXpub = String(testXpub.dropLast()) + (testXpub.last == "1" ? "2" : "1") XCTAssertThrowsError(try WatchOnlyAccountClaimCodec.encode(record: makeRecord(accountIndex: 1, xpub: invalidXpub))) { XCTAssertEqual($0 as? WatchOnlyAccountError, .invalidExtendedPublicKey) @@ -120,7 +127,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let xpub = testXpub let pending = makeRecord( accountIndex: 1, xpub: xpub, @@ -182,7 +189,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let xpub = testXpub let local = makeRecord( accountIndex: 1, xpub: xpub, @@ -212,13 +219,13 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let local = makeRecord( accountIndex: 1, - xpub: base58CheckEncode(Data(repeating: 1, count: 78)), + xpub: testXpub, setupState: .active, requestFingerprint: "local" ) let conflictingBackup = makeRecord( accountIndex: 1, - xpub: base58CheckEncode(Data(repeating: 2, count: 78)), + xpub: alternateTestXpub, setupState: .active, requestFingerprint: "restored" ) @@ -233,7 +240,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let xpub = testXpub let walletZero = makeRecord(accountIndex: 1, xpub: xpub, walletIndex: 0, setupState: .active) let walletOne = makeRecord(accountIndex: 1, xpub: xpub, walletIndex: 1, setupState: .active) try WatchOnlyAccountStore.save([walletZero, walletOne], defaults: defaults) @@ -252,7 +259,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - let xpub = base58CheckEncode(Data(repeating: 1, count: 78)) + let xpub = testXpub try WatchOnlyAccountStore.save( [ makeRecord(accountIndex: 1, xpub: xpub, setupState: .active), @@ -351,7 +358,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ) let record = makeRecord( accountIndex: accountIndex, - xpub: base58CheckEncode(Data(repeating: 1, count: 78)), + xpub: testXpub, isTrackingEnabled: false ) try WatchOnlyAccountStore.save([record], defaults: defaults) @@ -685,7 +692,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { ) let restored = makeRecord( accountIndex: 8, - xpub: base58CheckEncode(Data(repeating: 8, count: 78)), + xpub: testXpub, setupState: .active ) @@ -732,7 +739,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { try await activateSetupAuthorization(manager: manager, id: prepared.0.id) let restored = makeRecord( accountIndex: 8, - xpub: base58CheckEncode(Data(repeating: 8, count: 78)), + xpub: testXpub, setupState: .active ) @@ -760,7 +767,7 @@ final class WatchOnlyAccountServiceTests: XCTestCase { try await activateSetupAuthorization(manager: manager, id: prepared.0.id) let restored = makeRecord( accountIndex: 8, - xpub: base58CheckEncode(Data(repeating: 8, count: 78)), + xpub: testXpub, setupState: .active ) try await manager.restore([restored], allocationState: nil) @@ -813,10 +820,6 @@ final class WatchOnlyAccountServiceTests: XCTestCase { setupState: setupState ) } - - private func base58CheckEncode(_ payload: Data) -> String { - Base58.base58CheckEncode([UInt8](payload)) - } } private struct TrackingChange: Equatable { @@ -852,8 +855,7 @@ private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { failNextCreation = false throw FakeNodeError.creationFailed } - let rawXpub = Data((0 ..< WatchOnlyAccountClaimCodec.serializedXpubLength).map { UInt8(($0 + Int(accountIndex)) % 255 + 1) }) - return base58CheckEncode(rawXpub) + return accountIndex.isMultiple(of: 2) ? alternateTestXpub : testXpub } func setWatchOnlyAccountTracking( @@ -895,10 +897,6 @@ private final class FakeWatchOnlyAccountNode: WatchOnlyAccountNodeHandling { } trackedAccountIndexes.formUnion(desiredAccountIndexes) } - - private func base58CheckEncode(_ payload: Data) -> String { - Base58.base58CheckEncode([UInt8](payload)) - } } private final class LifecycleQueueProbe: @unchecked Sendable { From 44b430f2ff1c8f064d6360d3ea443c13b41bb554 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 15:21:55 +0200 Subject: [PATCH 13/16] test: cover watch-only retry behavior --- BitkitTests/PubkyAuthApprovalSheetTests.swift | 69 +++++++++++++++++++ Docs/watch-only-account-claim-v1.md | 8 +-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index ecd5c76bd..1f67a08fe 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -33,6 +33,24 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize) } + @MainActor + func testOrdinaryRequestUsesOrdinaryApproval() async throws { + let authUrl = "pubkyauth://signin?caps=/pub/example/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s" + let request = try PubkyAuthRequest.parse(url: authUrl) + var approvedCapabilities: String? + + try await PubkyService.approveAuthRequest( + request: request, + authUrl: authUrl, + accountName: "", + secretKeyHex: "secret", + ordinaryApproval: { _, capabilities, _ in approvedCapabilities = capabilities }, + companionApproval: { _, _, _ in XCTFail("Ordinary auth must not deliver a companion claim") } + ) + + XCTAssertEqual(approvedCapabilities, "/pub/example/:rw") + } + func testResolvePubkyApprovalLocalAuthModePrefersPinWhenPinEnabled() { let mode = resolvePubkyApprovalLocalAuthMode( isPinEnabled: true, @@ -274,6 +292,57 @@ final class PubkyAuthApprovalSheetTests: XCTestCase { XCTAssertEqual(node.trackingChanges, [true, true, true]) } + @MainActor + func testRetryAfterRestartReusesAccountAndPayload() async throws { + let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let authUrl = "pubkyauth://signin?caps=/pub/paykit/v0/bitkit/server/:rw&relay=https://httprelay.pubky.app/inbox/&secret=e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3t7e3s&x-bitkit-claim=watch-only-account-v1" + let request = try PubkyAuthRequest.parse(url: authUrl) + let node = ApprovalFakeWatchOnlyAccountNode() + let initialManager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + var deliveredPayloads: [Data] = [] + + await XCTAssertThrowsErrorAsync { + try await PubkyService.approveAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: initialManager, + companionApproval: { _, payload, _ in + deliveredPayloads.append(payload) + throw Paykit.PubkyAuthCompanionClaimApprovalError.AuthorizationFailure(reason: "normal auth failed") + } + ) + } + + let initialAccount = try XCTUnwrap(initialManager.accounts.first) + XCTAssertEqual(initialAccount.setupState, .authorizing) + XCTAssertTrue(initialAccount.isTrackingEnabled) + + let restartedManager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: node) + try await PubkyService.approveAuthRequest( + request: request, + authUrl: authUrl, + accountName: "Creator store", + secretKeyHex: "secret", + accountManager: restartedManager, + companionApproval: { _, payload, _ in deliveredPayloads.append(payload) } + ) + + let activeAccount = try XCTUnwrap(restartedManager.accounts.first) + XCTAssertEqual(deliveredPayloads.count, 2) + XCTAssertEqual(deliveredPayloads.first, deliveredPayloads.last) + XCTAssertEqual(activeAccount.id, initialAccount.id) + XCTAssertEqual(activeAccount.accountIndex, initialAccount.accountIndex) + XCTAssertEqual(activeAccount.xpub, initialAccount.xpub) + XCTAssertEqual(activeAccount.setupState, .active) + XCTAssertTrue(activeAccount.isTrackingEnabled) + XCTAssertEqual(node.trackingChanges, [true, true]) + } + @MainActor func testTrackingPreparationFailureUnloadsAccountBeforeApproval() async throws { let suiteName = "PubkyAuthApprovalSheetTests.\(UUID().uuidString)" diff --git a/Docs/watch-only-account-claim-v1.md b/Docs/watch-only-account-claim-v1.md index 6aa08e764..bc043e916 100644 --- a/Docs/watch-only-account-claim-v1.md +++ b/Docs/watch-only-account-claim-v1.md @@ -8,7 +8,7 @@ This document records the client contract implemented by Bitkit iOS and Android - The exact capability is `/pub/paykit/v0/bitkit/server/:rw`. - Missing, unknown, mismatched, or duplicate companion-claim parameters are rejected. - Every distinct auth request creates a fresh native-SegWit account, beginning at BIP84 account index `1`. Account indexes increase monotonically and are never reused. Retrying the same logical auth request reuses its incomplete account even if query parameters are reordered. -- The user assigns a local name to the account. The name is not disclosed in the claim. +- Bitkit automatically names the account from the requesting service. The user can rename it later. The local name is not disclosed in the claim. ## Claim payload @@ -41,9 +41,9 @@ The server verifies the signature with the creator's Pubky Ed25519 public key fr - The companion channel is `base_relay/{base64url_no_pad(BLAKE3(ASCII("watch-only-account-v1|") || secret))}`. - Paykit encrypts the complete 148-byte signed claim on the companion channel with the auth request secret using XSalsa20-Poly1305. - Paykit delivers the claim before approving the normal Pubky Auth token, avoiding a session that was authorized without its required account claim. -- Bitkit persists the account before delivery and retries the same pending claim idempotently. -- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Success marks it active and leaves tracking enabled; preparation or companion-delivery failure returns it to pending and unloads it again. -- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. Retrying the same request can then finish normal authorization without losing visibility into addresses the server may already have derived. +- Bitkit persists the account before delivery and reuses the same account index and unsigned xpub payload when retrying an incomplete setup. Each attempt may create new encrypted relay messages; delivery is not guaranteed exactly once. +- Bitkit durably marks and loads an incomplete account as authorizing before calling Paykit. Successful combined approval marks it active and leaves tracking enabled. An initial preparation or companion-delivery failure returns it to pending and unloads it again. +- If Paykit reports that companion delivery succeeded but normal AuthToken delivery failed, Bitkit leaves the account authorizing and tracked. The same conservative state is retained if local activation persistence fails after Paykit returns success. Retrying reruns the combined Paykit approval with the same account and xpub payload; retry failures keep the account tracked so Bitkit does not lose visibility into addresses the server may already have derived. - Disabling tracking unloads the account from LDK Node at runtime. It does not delete persisted wallet state, the xpub, or the server session. - Enabled active or authorizing accounts are configured before LDK Node starts. Electrum full scans use a batch size of `100` and stop gap of `1000`. - Bitkit pre-reveals external receive indexes `0...999` for each tracked account. LDK then maintains a rolling stop-gap window: the first address with transaction history must be at or below index `999`, and after activity at index `n`, the next active index must be at or below `n + 1000` so there are never `1000` consecutive inactive addresses. From 79b6043f324e1589525ac7fcb5e2946b7f769fe1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 16 Jul 2026 17:17:20 +0200 Subject: [PATCH 14/16] fix: align Paykit auth UI --- Bitkit/Components/SheetIntro.swift | 5 +- Bitkit/Models/PubkyAuthRequest.swift | 4 + .../PubkyAuthApprovalSheet.swift | 100 +++++++----------- BitkitTests/PubkyAuthRequestTests.swift | 12 ++- 4 files changed, 58 insertions(+), 63 deletions(-) diff --git a/Bitkit/Components/SheetIntro.swift b/Bitkit/Components/SheetIntro.swift index 5cc979a82..f4b8b4eac 100644 --- a/Bitkit/Components/SheetIntro.swift +++ b/Bitkit/Components/SheetIntro.swift @@ -10,6 +10,7 @@ struct SheetIntro: View { let accentColor: Color let accentFont: ((CGFloat) -> Font)? let testID: String? + let cancelTestID: String? let continueTestID: String? let onCancel: (() -> Void)? let onContinue: () -> Void @@ -27,6 +28,7 @@ struct SheetIntro: View { accentColor: Color = .brandAccent, accentFont: ((CGFloat) -> Font)? = nil, testID: String? = nil, + cancelTestID: String? = nil, continueTestID: String? = nil, onCancel: (() -> Void)? = nil, onContinue: @escaping () -> Void @@ -40,6 +42,7 @@ struct SheetIntro: View { self.accentColor = accentColor self.accentFont = accentFont self.testID = testID + self.cancelTestID = cancelTestID self.continueTestID = continueTestID self.onCancel = onCancel self.onContinue = onContinue @@ -88,7 +91,7 @@ struct SheetIntro: View { CustomButton(title: cancelText, variant: .secondary) { onCancel() } - .accessibilityIdentifier("\(baseTestID)Cancel") + .accessibilityIdentifier(cancelTestID ?? "\(baseTestID)Cancel") CustomButton(title: continueText) { onContinue() diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift index b5a353e12..24b287164 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -22,6 +22,10 @@ struct PubkyAuthPermission { let path: String let accessLevel: String + var displayPath: String { + path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + } + var displayAccess: String { var levels: [String] = [] if accessLevel.contains("r") { levels.append("READ") } diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index ae00058d7..2d79b2076 100644 --- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift +++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift @@ -103,25 +103,11 @@ struct PubkyAuthApprovalSheet: View { var body: some View { Sheet(id: .pubkyAuthApproval, data: config) { - VStack(alignment: .leading, spacing: 0) { - SheetHeader( - title: headerTitle, - showBackButton: showsBackButton, - onBack: onBack - ) - - switch state { - case .watchOnlyConsent: - watchOnlyConsentContent - case .authorize: - authorizeContent - case .authorizing: - authorizingContent - case .success: - successContent - } + if state == .watchOnlyConsent { + watchOnlyConsentContent + } else { + authorizationFlowContent } - .padding(.horizontal, 16) } .fullScreenCover(isPresented: $isShowingAuthCheck) { AuthCheck( @@ -142,54 +128,46 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Watch-Only Consent private var watchOnlyConsentContent: some View { - GeometryReader { geometry in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Image("coin-stack") - .resizable() - .scaledToFit() - .frame(width: 256, height: 256) - .frame(maxWidth: .infinity) - - Spacer() - .frame(height: 36) - - VStack(alignment: .leading, spacing: 8) { - DisplayText( - t("pubky_auth__watch_only_intro_title"), - accentColor: .blueAccent - ) - .fixedSize(horizontal: false, vertical: true) - - BodyMText(t("pubky_auth__watch_only_intro_description")) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) - } + SheetIntro( + navTitle: t("pubky_auth__watch_only_intro_nav_title"), + title: t("pubky_auth__watch_only_intro_title"), + description: t("pubky_auth__watch_only_intro_description"), + image: "coin-stack", + continueText: t("pubky_auth__watch_only_intro_approve"), + cancelText: t("common__cancel"), + accentColor: .blueAccent, + testID: "PubkyAuthWatchOnlyConsent", + cancelTestID: "PubkyAuthWatchOnlyCancel", + continueTestID: "PubkyAuthWatchOnlyApprove", + onCancel: { sheets.hideSheet() }, + onContinue: { _ = state.approveWatchOnlyConsent() } + ) + } - Spacer(minLength: 12) + // MARK: - Authorization - HStack(spacing: 16) { - CustomButton(title: t("common__cancel"), variant: .secondary) { - sheets.hideSheet() - } - .accessibilityIdentifier("PubkyAuthWatchOnlyCancel") + private var authorizationFlowContent: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader( + title: headerTitle, + showBackButton: showsBackButton, + onBack: onBack + ) - CustomButton(title: t("pubky_auth__watch_only_intro_approve")) { - _ = state.approveWatchOnlyConsent() - } - .accessibilityIdentifier("PubkyAuthWatchOnlyApprove") - } - } - .frame(minHeight: geometry.size.height, alignment: .top) - .padding(.horizontal, 16) + switch state { + case .watchOnlyConsent: + EmptyView() + case .authorize: + authorizeContent + case .authorizing: + authorizingContent + case .success: + successContent } - .scrollIndicators(.hidden) } - .accessibilityIdentifier("PubkyAuthWatchOnlyConsent") + .padding(.horizontal, 16) } - // MARK: - Authorization - private var authorizeContent: some View { VStack(alignment: .leading, spacing: 0) { approvalDetails @@ -261,7 +239,7 @@ struct PubkyAuthApprovalSheet: View { .padding(.bottom, 16) profileCard - .padding(.bottom, 24) + .padding(.bottom, 16) } .frame(minHeight: geometry.size.height, alignment: .top) } @@ -311,7 +289,7 @@ struct PubkyAuthApprovalSheet: View { .font(.system(size: 14)) .foregroundColor(.white) - BodySSBText(permission.path) + BodySSBText(permission.displayPath) .lineLimit(1) Spacer() diff --git a/BitkitTests/PubkyAuthRequestTests.swift b/BitkitTests/PubkyAuthRequestTests.swift index 4c309b2a6..ee600eb11 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -160,7 +160,17 @@ final class PubkyAuthRequestTests: XCTestCase { XCTAssertEqual(PubkyAuthRequest.extractServiceName("pub/pubky.app/"), "pubky.app") } - // MARK: - PubkyAuthPermission displayAccess + // MARK: - PubkyAuthPermission display + + func testDisplayPathRemovesCapabilitySeparator() { + let permission = PubkyAuthPermission(path: "/pub/paykit/v0/bitkit/server/", accessLevel: "rw") + XCTAssertEqual(permission.displayPath, "/pub/paykit/v0/bitkit/server") + } + + func testDisplayPathPreservesRoot() { + let permission = PubkyAuthPermission(path: "/", accessLevel: "r") + XCTAssertEqual(permission.displayPath, "/") + } func testDisplayAccessReadWrite() { let permission = PubkyAuthPermission(path: "/test", accessLevel: "rw") From 117ad8f8e907159e67084eb2a63f4e9f5f9f1a75 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 20 Jul 2026 13:18:07 +0200 Subject: [PATCH 15/16] feat: add Paykit receiver Noise keys --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../PrivatePaykitService+Payments.swift | 8 +- Bitkit/Services/PubkyService.swift | 115 ++++++++++++++++-- Bitkit/Utilities/Keychain.swift | 2 + .../PaykitReceiverNoiseKeyStoreTests.swift | 48 ++++++++ 6 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 16f627987..8ec56b790 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1173,7 +1173,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc36"; + version = "0.1.0-rc37"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9578ea1eb..8bd6a727b 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "e0c71ce1b3850193b21b4fb3d9475ce09ac05822", - "version" : "0.1.0-rc36" + "revision" : "c43ccbd468adb6c3f841682dec18b2804e5aaa16", + "version" : "0.1.0-rc37" } }, { diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 021126c01..486b81f66 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -22,9 +22,9 @@ extension PrivatePaykitService { } private func beginPrivateOrPublicPayment(to publicKey: String, wallet: WalletViewModel) async throws -> PublicPaykitPaymentLaunchResult { - let isPrivateCapable = await hasLocalSecretKeyForCurrentProfile() + let hasLiveSession = await hasLiveSessionForCurrentProfile() - if isPrivateCapable, await canPublishPrivateEndpoints(wallet: wallet) { + if hasLiveSession, await canPublishPrivateEndpoints(wallet: wallet) { _ = await refreshSavedContactEndpointsReturningError( for: [publicKey], wallet: wallet, @@ -73,9 +73,9 @@ extension PrivatePaykitService { } } - private func hasLocalSecretKeyForCurrentProfile() async -> Bool { + private func hasLiveSessionForCurrentProfile() async -> Bool { guard let status = try? await PaykitSdkService.shared.identityStatus() else { return false } - return status.privateLinkCapable + return status.liveSessionAvailable } func privatePayableEndpoints(from endpoints: [PublicPaykitService.Endpoint], publicKey: String) async -> [PublicPaykitService.Endpoint] { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index c32fc0cc9..43a92fd9b 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -301,7 +301,10 @@ actor PaykitSdkService { func initialize() async throws { try await operationLock.withLock { - _ = try await handle().initialize() + _ = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() + let sdk = try handle() + _ = try await sdk.initialize() + await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) } } @@ -329,9 +332,11 @@ actor PaykitSdkService { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil + let receiverNoiseSecretKey = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() let result = try await bootstrap().importSession( sessionSecret: secret, localSecretKey: localSecret, + receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: includeLocalSecret) @@ -343,8 +348,10 @@ actor PaykitSdkService { func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: receiverNoiseSecretKey, homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, requiredCapabilities: Self.requiredCapabilities() @@ -358,8 +365,10 @@ actor PaykitSdkService { func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) @@ -390,6 +399,7 @@ actor PaykitSdkService { do { result = try await request.complete( localSecretKey: nil, + receiverNoiseSecretKey: sessionProvider.loadOrCreateReceiverNoiseSecretKey(), requiredCapabilities: Self.requiredCapabilities() ) } catch { @@ -585,14 +595,7 @@ actor PaykitSdkService { return } - let status = try await sdk.identityStatus() - let capabilities = Paykit.PaykitReceiverCapabilities( - privatePayments: status?.privateLinkCapable == true, - paymentRequests: false, - receipts: false, - outgoingPayments: true - ) - _ = try await sdk.publishPaykitReceiverMarker(capabilities: capabilities) + _ = try await sdk.publishPaykitReceiverMarker(capabilities: receiverCapabilities(using: sdk)) } } @@ -813,6 +816,7 @@ actor PaykitSdkService { throw KeychainError.failedToSave } try Keychain.upsert(key: .paykitSession, data: sessionData) + try sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) guard shouldStoreLocalSecret, let localSecret = access.exportLocalSecretKey() else { try? Keychain.delete(key: .pubkySecretKey) @@ -836,7 +840,29 @@ actor PaykitSdkService { try? Keychain.delete(key: .paykitSdkState) } resetRuntime() - _ = try await handle().initialize() + let sdk = try handle() + _ = try await sdk.initialize() + await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) + } + + private func publishReceiverMarkerIfLiveSessionAvailable(using sdk: PaykitSdk) async { + do { + let capabilities = try await receiverCapabilities(using: sdk) + guard capabilities.privatePayments else { return } + _ = try await sdk.publishPaykitReceiverMarker(capabilities: capabilities) + } catch { + Logger.warn("Failed to publish Paykit receiver marker: \(error)", context: "PaykitSdkService") + } + } + + private func receiverCapabilities(using sdk: PaykitSdk) async throws -> Paykit.PaykitReceiverCapabilities { + let status = try await sdk.identityStatus() + return Paykit.PaykitReceiverCapabilities( + privatePayments: status?.liveSessionAvailable == true, + paymentRequests: false, + receipts: false, + outgoingPayments: true + ) } private func currentSdkStatePublicKey() async -> String? { @@ -975,6 +1001,7 @@ private final class PaykitSdkStateBlobStore: SdkStateBlobStore, @unchecked Senda private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecked Sendable { private let lock = NSLock() + private let receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore() private var liveSessionAccess: PubkySessionAccess? func setLiveSessionAccess(_ access: PubkySessionAccess) { @@ -1004,7 +1031,8 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke return try PubkySessionAccess( sessionSecret: sessionSecret, - localSecretKey: loadLocalSecretKey() + localSecretKey: loadLocalSecretKey(), + receiverNoiseSecretKey: loadOrCreateReceiverNoiseSecretKey() ) } @@ -1025,6 +1053,71 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke return try PaykitSdkService.localSecretKey(fromHex: secretKeyHex) } + + func loadOrCreateReceiverNoiseSecretKey() throws -> ReceiverNoiseSecretKey { + try receiverNoiseKeyStore.loadOrCreate() + } + + func persistReceiverNoiseSecretKey(_ key: ReceiverNoiseSecretKey) throws { + try receiverNoiseKeyStore.persist(key) + } +} + +final class PaykitReceiverNoiseKeyStore: @unchecked Sendable { + private static let keyLength = 32 + + private let lock = NSLock() + private let loadBytes: () throws -> Data? + private let upsertBytes: (Data) throws -> Void + + init( + loadBytes: @escaping () throws -> Data? = { try Keychain.load(key: .paykitReceiverNoiseSecretKey) }, + upsertBytes: @escaping (Data) throws -> Void = { try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: $0) } + ) { + self.loadBytes = loadBytes + self.upsertBytes = upsertBytes + } + + func loadOrCreate() throws -> ReceiverNoiseSecretKey { + lock.lock() + defer { lock.unlock() } + + if let bytes = try loadBytes() { + return try key(from: bytes) + } + + let key = ReceiverNoiseSecretKey.random() + try upsertBytes(key.exportBytes()) + return key + } + + func persist(_ key: ReceiverNoiseSecretKey) throws { + lock.lock() + defer { lock.unlock() } + + let bytes = key.exportBytes() + guard bytes.count == Self.keyLength else { + throw invalidKeyError("Paykit returned an invalid receiver Noise key") + } + if let storedBytes = try loadBytes() { + guard storedBytes == bytes else { + throw invalidKeyError("Paykit receiver Noise key changed unexpectedly") + } + return + } + try upsertBytes(bytes) + } + + private func key(from bytes: Data) throws -> ReceiverNoiseSecretKey { + guard bytes.count == Self.keyLength else { + throw invalidKeyError("Stored Paykit receiver Noise key is invalid") + } + return ReceiverNoiseSecretKey(bytes: bytes) + } + + private func invalidKeyError(_ context: String) -> PaykitError { + PaykitError.Identity(code: "invalid_receiver_noise_secret_key", context: context) + } } private final class PaykitSdkPaymentAdapter: SdkPaymentAdapter, @unchecked Sendable { diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index a05778260..49e45f723 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 paykitReceiverNoiseSecretKey case paykitSdkState case pubkySecretKey @@ -17,6 +18,7 @@ enum KeychainEntryType { case .pushNotificationPrivateKey: "push_notification_private_key" case .securityPin: "security_pin" case .paykitSession: "paykit_session" + case .paykitReceiverNoiseSecretKey: "paykit_receiver_noise_secret_key" case .paykitSdkState: "paykit_sdk_state" case .pubkySecretKey: "pubky_secret_key" } diff --git a/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift new file mode 100644 index 000000000..29bddcbf9 --- /dev/null +++ b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift @@ -0,0 +1,48 @@ +@testable import Bitkit +import Foundation +import Paykit +import XCTest + +final class PaykitReceiverNoiseKeyStoreTests: XCTestCase { + func testGeneratesPersistsAndReusesReceiverNoiseKey() throws { + var persistedBytes: Data? + let store = PaykitReceiverNoiseKeyStore( + loadBytes: { persistedBytes }, + upsertBytes: { persistedBytes = $0 } + ) + + let first = try store.loadOrCreate().exportBytes() + let second = try store.loadOrCreate().exportBytes() + let restoredStore = PaykitReceiverNoiseKeyStore( + loadBytes: { persistedBytes }, + upsertBytes: { persistedBytes = $0 } + ) + let restored = try restoredStore.loadOrCreate().exportBytes() + + XCTAssertEqual(first.count, 32) + XCTAssertEqual(persistedBytes, first) + XCTAssertEqual(second, first) + XCTAssertEqual(restored, first) + XCTAssertEqual(KeychainEntryType.paykitReceiverNoiseSecretKey.storageKey, "paykit_receiver_noise_secret_key") + } + + func testRejectsUnexpectedReceiverNoiseKeyReplacement() throws { + var persistedBytes: Data? = Data(repeating: 1, count: 32) + let store = PaykitReceiverNoiseKeyStore( + loadBytes: { persistedBytes }, + upsertBytes: { persistedBytes = $0 } + ) + + XCTAssertThrowsError(try store.persist(ReceiverNoiseSecretKey(bytes: Data(repeating: 2, count: 32)))) + XCTAssertEqual(persistedBytes, Data(repeating: 1, count: 32)) + } + + func testRejectsInvalidPersistedReceiverNoiseKey() { + let store = PaykitReceiverNoiseKeyStore( + loadBytes: { Data(repeating: 0, count: 31) }, + upsertBytes: { _ in XCTFail("Invalid bytes must not be overwritten") } + ) + + XCTAssertThrowsError(try store.loadOrCreate()) + } +} From 67d11d09e961fe73669a3b03f2535e40207bdfd0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 20 Jul 2026 13:41:45 +0200 Subject: [PATCH 16/16] fix: derive Paykit Noise key from wallet seed --- Bitkit/Services/PubkyService.swift | 111 +++++++++++++----- .../PaykitReceiverNoiseKeyStoreTests.swift | 46 ++++++-- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 43a92fd9b..924abb5e8 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1,4 +1,6 @@ +import BitkitCore import Combine +import CryptoKit import Foundation import Paykit @@ -301,7 +303,6 @@ actor PaykitSdkService { func initialize() async throws { try await operationLock.withLock { - _ = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() let sdk = try handle() _ = try await sdk.initialize() await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) @@ -332,7 +333,7 @@ actor PaykitSdkService { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil - let receiverNoiseSecretKey = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().importSession( sessionSecret: secret, localSecretKey: localSecret, @@ -348,7 +349,7 @@ actor PaykitSdkService { func signUp(secretKeyHex: String, homeserverPublicKey: String, signupCode: String?) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() - let receiverNoiseSecretKey = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), receiverNoiseSecretKey: receiverNoiseSecretKey, @@ -365,7 +366,7 @@ actor PaykitSdkService { func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() - let receiverNoiseSecretKey = try sessionProvider.loadOrCreateReceiverNoiseSecretKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), receiverNoiseSecretKey: receiverNoiseSecretKey, @@ -399,7 +400,7 @@ actor PaykitSdkService { do { result = try await request.complete( localSecretKey: nil, - receiverNoiseSecretKey: sessionProvider.loadOrCreateReceiverNoiseSecretKey(), + receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities: Self.requiredCapabilities() ) } catch { @@ -1032,7 +1033,7 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke return try PubkySessionAccess( sessionSecret: sessionSecret, localSecretKey: loadLocalSecretKey(), - receiverNoiseSecretKey: loadOrCreateReceiverNoiseSecretKey() + receiverNoiseSecretKey: loadOrDeriveReceiverNoiseSecretKey() ) } @@ -1054,8 +1055,8 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke return try PaykitSdkService.localSecretKey(fromHex: secretKeyHex) } - func loadOrCreateReceiverNoiseSecretKey() throws -> ReceiverNoiseSecretKey { - try receiverNoiseKeyStore.loadOrCreate() + func loadOrDeriveReceiverNoiseSecretKey() throws -> ReceiverNoiseSecretKey { + try receiverNoiseKeyStore.loadOrDerive() } func persistReceiverNoiseSecretKey(_ key: ReceiverNoiseSecretKey) throws { @@ -1063,32 +1064,70 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke } } +enum PaykitReceiverNoiseKeyDerivation { + private static let domain = "bitkit/paykit/receiver-noise-key" + private static let version = "v1" + + static func deriveFromWalletSeed( + mnemonic: String, + passphrase: String?, + network: String, + receiverPath: String + ) throws -> Data { + var seed = try BitkitCore.mnemonicToSeed( + mnemonicPhrase: mnemonic, + passphrase: passphrase?.isEmpty == true ? nil : passphrase + ) + defer { seed.resetBytes(in: seed.startIndex ..< seed.endIndex) } + return derive(seed: seed, network: network, receiverPath: receiverPath) + } + + static func derive(seed: Data, network: String, receiverPath: String) -> Data { + let domainBytes = Data(domain.utf8) + let salt = Data(SHA256.hash(data: domainBytes)) + var prk = Data(HMAC.authenticationCode(for: seed, using: SymmetricKey(data: salt))) + defer { prk.resetBytes(in: prk.startIndex ..< prk.endIndex) } + + var expandInput = Data("\(version)\0\(network)\0\(receiverPath)".utf8) + expandInput.append(0x01) + return Data(HMAC.authenticationCode(for: expandInput, using: SymmetricKey(data: prk))) + } +} + final class PaykitReceiverNoiseKeyStore: @unchecked Sendable { private static let keyLength = 32 private let lock = NSLock() private let loadBytes: () throws -> Data? private let upsertBytes: (Data) throws -> Void + private let deriveBytes: () throws -> Data + private var validatedBytes: Data? init( loadBytes: @escaping () throws -> Data? = { try Keychain.load(key: .paykitReceiverNoiseSecretKey) }, - upsertBytes: @escaping (Data) throws -> Void = { try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: $0) } + upsertBytes: @escaping (Data) throws -> Void = { try Keychain.upsert(key: .paykitReceiverNoiseSecretKey, data: $0) }, + deriveBytes: @escaping () throws -> Data = { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)), !mnemonic.isEmpty else { + throw CustomServiceError.mnemonicNotFound + } + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: 0)) + return try PaykitReceiverNoiseKeyDerivation.deriveFromWalletSeed( + mnemonic: mnemonic, + passphrase: passphrase, + network: Env.networkName, + receiverPath: PaykitReceiverPath.wallet + ) + } ) { self.loadBytes = loadBytes self.upsertBytes = upsertBytes + self.deriveBytes = deriveBytes } - func loadOrCreate() throws -> ReceiverNoiseSecretKey { + func loadOrDerive() throws -> ReceiverNoiseSecretKey { lock.lock() defer { lock.unlock() } - - if let bytes = try loadBytes() { - return try key(from: bytes) - } - - let key = ReceiverNoiseSecretKey.random() - try upsertBytes(key.exportBytes()) - return key + return try ReceiverNoiseSecretKey(bytes: validatedKeyBytes()) } func persist(_ key: ReceiverNoiseSecretKey) throws { @@ -1096,23 +1135,35 @@ final class PaykitReceiverNoiseKeyStore: @unchecked Sendable { defer { lock.unlock() } let bytes = key.exportBytes() - guard bytes.count == Self.keyLength else { - throw invalidKeyError("Paykit returned an invalid receiver Noise key") + let expectedBytes = try validatedKeyBytes() + guard bytes == expectedBytes else { + throw invalidKeyError("Paykit receiver Noise key changed unexpectedly") + } + } + + private func validatedKeyBytes() throws -> Data { + if let validatedBytes { + return validatedBytes + } + + let derivedBytes = try deriveBytes() + guard derivedBytes.count == Self.keyLength else { + throw invalidKeyError("Derived Paykit receiver Noise key is invalid") } + if let storedBytes = try loadBytes() { - guard storedBytes == bytes else { - throw invalidKeyError("Paykit receiver Noise key changed unexpectedly") + guard storedBytes.count == Self.keyLength else { + throw invalidKeyError("Stored Paykit receiver Noise key is invalid") } - return + guard storedBytes == derivedBytes else { + throw invalidKeyError("Stored Paykit receiver Noise key does not match the wallet seed") + } + } else { + try upsertBytes(derivedBytes) } - try upsertBytes(bytes) - } - private func key(from bytes: Data) throws -> ReceiverNoiseSecretKey { - guard bytes.count == Self.keyLength else { - throw invalidKeyError("Stored Paykit receiver Noise key is invalid") - } - return ReceiverNoiseSecretKey(bytes: bytes) + validatedBytes = derivedBytes + return derivedBytes } private func invalidKeyError(_ context: String) -> PaykitError { diff --git a/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift index 29bddcbf9..017b2828d 100644 --- a/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift +++ b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift @@ -4,20 +4,36 @@ import Paykit import XCTest final class PaykitReceiverNoiseKeyStoreTests: XCTestCase { - func testGeneratesPersistsAndReusesReceiverNoiseKey() throws { + func testDerivationMatchesVersionedCrossPlatformVector() { + let seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + .hexaData + + let key = PaykitReceiverNoiseKeyDerivation.derive( + seed: seed, + network: "bitcoin", + receiverPath: "bitkit/wallet" + ) + + XCTAssertEqual(key.hex, "500f4799bbb2d02103e3b74b365ddb478a3187333c053fa9eb62f4052ba6a327") + } + + func testDerivesPersistsAndReusesReceiverNoiseKey() throws { var persistedBytes: Data? + let derivedBytes = Data(repeating: 7, count: 32) let store = PaykitReceiverNoiseKeyStore( loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 } + upsertBytes: { persistedBytes = $0 }, + deriveBytes: { derivedBytes } ) - let first = try store.loadOrCreate().exportBytes() - let second = try store.loadOrCreate().exportBytes() + let first = try store.loadOrDerive().exportBytes() + let second = try store.loadOrDerive().exportBytes() let restoredStore = PaykitReceiverNoiseKeyStore( loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 } + upsertBytes: { persistedBytes = $0 }, + deriveBytes: { derivedBytes } ) - let restored = try restoredStore.loadOrCreate().exportBytes() + let restored = try restoredStore.loadOrDerive().exportBytes() XCTAssertEqual(first.count, 32) XCTAssertEqual(persistedBytes, first) @@ -30,7 +46,8 @@ final class PaykitReceiverNoiseKeyStoreTests: XCTestCase { var persistedBytes: Data? = Data(repeating: 1, count: 32) let store = PaykitReceiverNoiseKeyStore( loadBytes: { persistedBytes }, - upsertBytes: { persistedBytes = $0 } + upsertBytes: { persistedBytes = $0 }, + deriveBytes: { Data(repeating: 1, count: 32) } ) XCTAssertThrowsError(try store.persist(ReceiverNoiseSecretKey(bytes: Data(repeating: 2, count: 32)))) @@ -40,9 +57,20 @@ final class PaykitReceiverNoiseKeyStoreTests: XCTestCase { func testRejectsInvalidPersistedReceiverNoiseKey() { let store = PaykitReceiverNoiseKeyStore( loadBytes: { Data(repeating: 0, count: 31) }, - upsertBytes: { _ in XCTFail("Invalid bytes must not be overwritten") } + upsertBytes: { _ in XCTFail("Invalid bytes must not be overwritten") }, + deriveBytes: { Data(repeating: 0, count: 32) } + ) + + XCTAssertThrowsError(try store.loadOrDerive()) + } + + func testRejectsCachedKeyFromAnotherWalletSeed() { + let store = PaykitReceiverNoiseKeyStore( + loadBytes: { Data(repeating: 1, count: 32) }, + upsertBytes: { _ in XCTFail("Mismatched bytes must not be overwritten") }, + deriveBytes: { Data(repeating: 2, count: 32) } ) - XCTAssertThrowsError(try store.loadOrCreate()) + XCTAssertThrowsError(try store.loadOrDerive()) } }