diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 5dd365ec2..a2cdb4832 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/_CodeSignature", + "${TARGET_BUILD_DIR}/${WRAPPER_NAME}/Frameworks/vss_rust_client_ffiFFI.framework/_CodeSignature", ); name = "Remove Static Framework Stubs"; outputFileListPaths = ( @@ -765,6 +769,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; @@ -797,6 +802,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; @@ -1169,7 +1175,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc33"; + version = "0.1.0-rc37"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { @@ -1193,7 +1199,7 @@ repositoryURL = "https://github.com/synonymdev/ldk-node"; requirement = { kind = exactVersion; - version = "0.7.0-rc.53"; + version = "0.7.0-rc.57"; }; }; 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */ = { @@ -1201,7 +1207,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.4.1; + version = 0.4.2; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cee2b76ce..35ff21b4d 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/bitkit-core", "state" : { - "revision" : "aa32dbf3934871baa1a44576afdd2a92acb6bdd0", - "version" : "0.4.1" + "revision" : "dd99b46d4b849a7716b45664136c4d1ce5e5905e", + "version" : "0.4.2" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/synonymdev/ldk-node", "state" : { - "revision" : "f53a13e11d74296bea82c4fb616bad79901969b5", - "version" : "0.7.0-rc.53" + "revision" : "e676d20cd2ba6277f68619dc32261f541aa3bdfe", + "version" : "0.7.0-rc.57" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "aa15cae9c18f7a8643162d127320aa006ffb1afd", - "version" : "0.1.0-rc33" + "revision" : "c43ccbd468adb6c3f841682dec18b2804e5aaa16", + "version" : "0.1.0-rc37" } }, { 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/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/MainNavView.swift b/Bitkit/MainNavView.swift index 1813fc556..2b458fd25 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -574,6 +574,7 @@ struct MainNavView: View { case .electrumSettings: ElectrumSettingsScreen() case .rgsSettings: RgsSettingsScreen() case .addressViewer: AddressViewer() + case .watchOnlyAccounts: WatchOnlyAccountsView() case .devSettings: DevSettingsView() // Dev settings @@ -665,7 +666,7 @@ struct MainNavView: View { } private func shouldOpenPaymentSheet(for uri: String) -> Bool { - !SamRockSetupRequest.isProtocolURL(uri) + !SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri) } private func sanitizedDeeplinkDescription(_ url: URL) -> String { diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 9093948a5..9599ddd60 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -131,6 +131,16 @@ class ScannerManager: ObservableObject { Haptics.play(.scanSuccess) + guard !PubkyAuthRequest.isProtocolURL(uri) else { + app.toast( + type: .error, + title: t("other__qr_error_header"), + description: t("other__qr_error_text") + ) + completion(nil) + return + } + do { if handlePubkyRouteIfNeeded(uri, hiding: .send, reason: "Send scanner routed pubky key") { completion(nil) @@ -162,7 +172,7 @@ class ScannerManager: ObservableObject { } private func shouldOpenPaymentFlow(for uri: String) -> Bool { - !SamRockSetupRequest.isProtocolURL(uri) + !SamRockSetupRequest.isProtocolURL(uri) && !PubkyAuthRequest.isProtocolURL(uri) } private func handleElectrumScan(_ uri: String) async { diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index cfe41e312..e3f38fef1 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -9,6 +9,8 @@ struct WalletBackupV1: Codable { let transfers: [Transfer] 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 3607770c8..aaca814e5 100644 --- a/Bitkit/Models/PubkyAuthRequest.swift +++ b/Bitkit/Models/PubkyAuthRequest.swift @@ -1,12 +1,31 @@ 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 missingBitkitClaim + case duplicateBitkitClaim + case unsupportedBitkitClaim(String) + case invalidBitkitClaimCapabilities +} + // MARK: - PubkyAuth Permission 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") } @@ -24,22 +43,57 @@ struct PubkyAuthRequest { let capabilities: String let permissions: [PubkyAuthPermission] let serviceNames: [String] + let bitkitClaim: PubkyAuthClaim? + + static func isProtocolURL(_ value: String) -> Bool { + URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.scheme?.lowercased() == "pubkyauth" + } 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 { + if capabilities == PubkyAuthClaim.watchOnlyAccountCapabilities { + throw PubkyAuthRequestError.missingBitkitClaim + } + 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 495618475..7ba1e28c6 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -664,6 +664,14 @@ "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_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_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..."; "pubky_auth__success_title" = "Authorization Successful"; @@ -677,6 +685,25 @@ "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. 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_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"; +"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..d88a33559 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -207,6 +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 await WatchOnlyAccountManager.shared.restore( + payload.watchOnlyAccounts, + allocationState: payload.watchOnlyAccountAllocationState + ) pendingPaykitSdkBackupState = payload.paykitSdkBackupState didRestoreWalletBackup = true @@ -369,6 +373,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) @@ -703,12 +715,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 + paykitSdkBackupState: paykitSdkBackupState, + watchOnlyAccounts: watchOnlyAccountSnapshot.accounts, + watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState ) return try JSONEncoder().encode(payload) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 936b28857..b9a82de74 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 @@ -105,6 +107,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()) @@ -117,7 +122,9 @@ class LightningService { lightningWalletSyncIntervalSecs: Env.walletSyncIntervalSecs, feeRateCacheUpdateIntervalSecs: Env.walletSyncIntervalSecs ), - connectionTimeoutSecs: 10 + connectionTimeoutSecs: 10, + additionalWalletFullScanBatchSize: 100, + additionalWalletFullScanStopGap: 1000 ) builder.setChainSourceElectrum(serverUrl: resolvedElectrumServerUrl, config: electrumConfig) @@ -279,6 +286,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() @@ -480,6 +495,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() @@ -501,6 +520,133 @@ class LightningService { } } + func exportWatchOnlyAccountXpub(accountIndex: UInt32, addressType: LDKNode.AddressType) async throws -> String { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + return try await ServiceQueue.background(.ldk) { + try node.exportOnchainWalletAccountXpub(addressType: addressType, accountIndex: accountIndex) + } + } + + func setWatchOnlyAccountTracking( + accountIndex: UInt32, + addressType: LDKNode.AddressType, + xpub: String, + enabled: Bool + ) async throws { + guard let node else { + throw AppError(serviceError: .nodeNotSetup) + } + + 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) + } + } + } + + #if !BITKIT_NOTIFICATION_EXTENSION + 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) + + 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 + } + if !isTracked { + try node.addOnchainWalletAccount( + 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 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 + ) + } + } + #endif + func newAddress() async throws -> String { guard let node else { throw AppError(serviceError: .nodeNotSetup) @@ -1271,14 +1417,12 @@ extension LightningService { Logger.info( "🫰 Payment claimable: paymentId: \(paymentId) paymentHash: \(paymentHash) claimableAmountMsat: \(claimableAmountMsat)" ) - case let .probeSuccessful(paymentId, paymentHash, routeFeeMsat): - let routeFeeText = routeFeeMsat.map(String.init) ?? "unknown" - Logger.info("🤑 Probe successful: paymentId: \(paymentId) paymentHash: \(paymentHash) routeFeeMsat: \(routeFeeText)") - case let .probeFailed(paymentId, paymentHash, shortChannelId, routeFeeMsat): - let routeFeeText = routeFeeMsat.map(String.init) ?? "unknown" + 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)) routeFeeMsat: \(routeFeeText)" + "❌ 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 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 6be3f56ca..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 @@ -95,6 +97,99 @@ 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 } + // Paykit documents AuthorizationFailure as the post-delivery case; unknown errors do not imply delivery. + if case .AuthorizationFailure = approvalError { + return true + } + 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. @@ -208,7 +303,9 @@ actor PaykitSdkService { func initialize() async throws { try await operationLock.withLock { - _ = try await handle().initialize() + let sdk = try handle() + _ = try await sdk.initialize() + await publishReceiverMarkerIfLiveSessionAvailable(using: sdk) } } @@ -236,9 +333,11 @@ actor PaykitSdkService { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() let localSecret = includeLocalSecret ? try sessionProvider.loadLocalSecretKey() : nil + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().importSession( sessionSecret: secret, localSecretKey: localSecret, + receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: includeLocalSecret) @@ -250,8 +349,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.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().signUp( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: receiverNoiseSecretKey, homeserverPublicKey: homeserverPublicKey, signupCode: signupCode, requiredCapabilities: Self.requiredCapabilities() @@ -265,8 +366,10 @@ actor PaykitSdkService { func signIn(secretKeyHex: String) async throws -> PubkySessionBootstrapResult { try await operationLock.withLock { let previousPublicKey = await currentSdkStatePublicKey() + let receiverNoiseSecretKey = try sessionProvider.loadOrDeriveReceiverNoiseSecretKey() let result = try await bootstrap().signIn( localSecretKey: Self.localSecretKey(fromHex: secretKeyHex), + receiverNoiseSecretKey: receiverNoiseSecretKey, requiredCapabilities: Self.requiredCapabilities() ) try await activateBootstrapResult(result, previousPublicKey: previousPublicKey, shouldStoreLocalSecret: true) @@ -297,6 +400,7 @@ actor PaykitSdkService { do { result = try await request.complete( localSecretKey: nil, + receiverNoiseSecretKey: sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), requiredCapabilities: Self.requiredCapabilities() ) } catch { @@ -334,6 +438,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 { @@ -476,14 +596,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)) } } @@ -704,6 +817,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) @@ -727,7 +841,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? { @@ -866,6 +1002,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) { @@ -895,7 +1032,8 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke return try PubkySessionAccess( sessionSecret: sessionSecret, - localSecretKey: loadLocalSecretKey() + localSecretKey: loadLocalSecretKey(), + receiverNoiseSecretKey: loadOrDeriveReceiverNoiseSecretKey() ) } @@ -916,6 +1054,121 @@ private final class PaykitSdkSessionProvider: SdkPubkySessionProvider, @unchecke return try PaykitSdkService.localSecretKey(fromHex: secretKeyHex) } + + func loadOrDeriveReceiverNoiseSecretKey() throws -> ReceiverNoiseSecretKey { + try receiverNoiseKeyStore.loadOrDerive() + } + + func persistReceiverNoiseSecretKey(_ key: ReceiverNoiseSecretKey) throws { + try receiverNoiseKeyStore.persist(key) + } +} + +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) }, + 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 loadOrDerive() throws -> ReceiverNoiseSecretKey { + lock.lock() + defer { lock.unlock() } + return try ReceiverNoiseSecretKey(bytes: validatedKeyBytes()) + } + + func persist(_ key: ReceiverNoiseSecretKey) throws { + lock.lock() + defer { lock.unlock() } + + let bytes = key.exportBytes() + 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.count == Self.keyLength else { + throw invalidKeyError("Stored Paykit receiver Noise key is invalid") + } + guard storedBytes == derivedBytes else { + throw invalidKeyError("Stored Paykit receiver Noise key does not match the wallet seed") + } + } else { + try upsertBytes(derivedBytes) + } + + validatedBytes = derivedBytes + return derivedBytes + } + + 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/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift new file mode 100644 index 000000000..0d13db020 --- /dev/null +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -0,0 +1,922 @@ +import BitkitCore +import Combine +import CryptoKit +import Foundation +import LDKNode + +enum WatchOnlyAccountSetupState: String, Codable { + case pendingDelivery + case authorizing + 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)'" + } +} + +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: + t("pubky_auth__watch_only_account_xpub_error") + } + } +} + +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 {} + +struct WatchOnlyAccountAllocationState: Codable, Equatable { + var highestAccountIndexByWallet: [String: UInt32] = [:] + var pendingAccountIndexByRequest: [String: UInt32] = [:] +} + +private struct WatchOnlyAccountData: Codable { + var accounts: [WatchOnlyAccountRecord] = [] + var allocationState = WatchOnlyAccountAllocationState() + var accountsPendingUnload: [WatchOnlyAccountRecord] = [] +} + +struct WatchOnlyAccountBackupSnapshot { + let accounts: [WatchOnlyAccountRecord] + let allocationState: WatchOnlyAccountAllocationState +} + +struct WatchOnlyAccountReconciliationSnapshot { + let accounts: [WatchOnlyAccountRecord] + let managedAccounts: [WatchOnlyAccountRecord] +} + +enum WatchOnlyAccountStore { + static let walletBackupDataChangedPublisher = walletBackupDataChangedSubject.eraseToAnyPublisher() + + static let dataKey = "watchOnlyAccountDataV1" + + private static let maximumAccountIndex = UInt32(Int32.max) + private static let walletBackupDataChangedSubject = PassthroughSubject() + + static func load(defaults: UserDefaults = .standard) throws -> [WatchOnlyAccountRecord] { + try loadData(defaults: defaults).accounts.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 { + 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 restoredInput = records ?? [] + let restoredAccounts = sanitizedAccounts(restoredInput) + var data = (try? loadData(defaults: defaults)) ?? WatchOnlyAccountData() + let currentAccounts = data.accounts + let locallyManagedAccounts = uniqueAccounts(currentAccounts + data.accountsPendingUnload) + + let protectedLocalAccounts = sanitizedAccounts(locallyManagedAccounts.filter { localAccount in + localAccount.setupState == .authorizing + || shouldPreserveLocalAccount(localAccount, from: restoredAccounts) + }) + 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: validHighestAccountIndexes(data.allocationState.highestAccountIndexByWallet), + pendingAccountIndexByRequest: validPendingAccountIndexes(data.allocationState.pendingAccountIndexByRequest) + ) + localAllocationState.reconcileAccountIndexes(locallyManagedAccounts) + localAllocationState.raiseHighWaterMarksForPendingReservations() + + var highestAccountIndexByWallet = localAllocationState.highestAccountIndexByWallet + for (walletKey, restoredIndex) in validHighestAccountIndexes( + restoredAllocationState?.highestAccountIndexByWallet ?? [:] + ) { + highestAccountIndexByWallet[walletKey] = max( + highestAccountIndexByWallet[walletKey] ?? 0, + restoredIndex + ) + } + + var allocationState = WatchOnlyAccountAllocationState( + highestAccountIndexByWallet: highestAccountIndexByWallet, + 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 = allocationState + 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 data.accountsPendingUnload.contains(where: { $0.walletIndex == walletIndex }) else { return } + data.accountsPendingUnload = data.accountsPendingUnload.filter { $0.walletIndex != walletIndex } + 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 + } + + let walletKey = String(walletIndex) + let highestPersistedAccountIndex = data.accounts + .filter { $0.walletIndex == walletIndex && isValidAccountIndex($0.accountIndex) } + .map(\.accountIndex) + .max() ?? 0 + let highestAccountIndex = max( + data.allocationState.highestAccountIndexByWallet[walletKey] ?? 0, + highestPersistedAccountIndex + ) + guard highestAccountIndex < maximumAccountIndex 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 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 { + throw WatchOnlyAccountError.authorizationAccountMissing + } + + 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 } + } + + static func clear(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: dataKey) + } + + private static func loadData(defaults: UserDefaults) throws -> WatchOnlyAccountData { + 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 { + try defaults.set(JSONEncoder().encode(data), forKey: dataKey) + walletBackupDataChangedSubject.send() + } + + 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 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], + blockedAccounts: [WatchOnlyAccountRecord], + localPendingAccountIndexes: [String: UInt32], + restoredPendingAccountIndexes: [String: UInt32], + localHighestAccountIndexByWallet: [String: UInt32] + ) -> [String: UInt32] { + 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) + }) + 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 } + let slot = allocationSlotKey(walletIndex: walletIndex, accountIndex: accountIndex) + 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 + ) + } + 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) + } + + return pendingAccountIndexes + } + + private static func sanitizedAccounts(_ accounts: [WatchOnlyAccountRecord]) -> [WatchOnlyAccountRecord] { + var ids = Set() + var managementKeys = Set() + var incompleteRequestKeys = Set() + var sanitized: [WatchOnlyAccountRecord] = [] + + for input in accounts where isUsableAccount(input) { + let account = normalizedTrackingState(input) + let accountManagementKey = managementKey(account) + let requestKey = allocationRequestKey( + walletIndex: account.walletIndex, + requestFingerprint: account.requestFingerprint + ) + guard !ids.contains(account.id), + !managementKeys.contains(accountManagementKey), + account.setupState == .active || !incompleteRequestKeys.contains(requestKey) + else { continue } + ids.insert(account.id) + managementKeys.insert(accountManagementKey) + if account.setupState != .active { + incompleteRequestKeys.insert(requestKey) + } + 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 { + 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 shouldPreserveLocalAccount( + _ localAccount: WatchOnlyAccountRecord, + from restoredAccounts: [WatchOnlyAccountRecord] + ) -> Bool { + let conflicts = restoredAccounts.filter { + $0.id == localAccount.id || managementKey($0) == managementKey(localAccount) + } + guard !conflicts.isEmpty else { return false } + if conflicts.contains(where: { !hasSameOwner(localAccount, $0) }) { + return true + } + return localAccount.setupState == .active + && conflicts.allSatisfy { $0.setupState != .active } + } + + private static func hasSameOwner(_ lhs: WatchOnlyAccountRecord, _ rhs: WatchOnlyAccountRecord) -> Bool { + managementKey(lhs) == managementKey(rhs) + && lhs.requestFingerprint == rhs.requestFingerprint + && lhs.xpub == rhs.xpub + } + + private static func managementKey(_ account: WatchOnlyAccountRecord) -> String { + "\(account.walletIndex):\(account.addressType):\(account.accountIndex)" + } +} + +private extension WatchOnlyAccountAllocationState { + mutating func reconcileAccountIndexes(_ accounts: [WatchOnlyAccountRecord]) { + 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 } + let walletKey = String(walletIndex) + 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 { + 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 { + static let shared = WatchOnlyAccountManager() + private static let companionClaimQueryParameter = "x-bitkit-claim" + + private(set) var accounts: [WatchOnlyAccountRecord] + + private let defaults: UserDefaults + private let lifecycleCoordinator: WatchOnlyAccountLifecycleCoordinator + private let node: WatchOnlyAccountNodeHandling + 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) + } 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 prepareUnsignedClaim(authUrl: String, name: String) async throws -> (WatchOnlyAccountRecord, Data) { + let normalizedName = try Self.normalizedName(name) + let fingerprint = Self.requestFingerprint(authUrl) + return try await prepareUnsignedClaim( + normalizedName: normalizedName, + fingerprint: fingerprint, + walletIndex: node.currentWalletIndex + ) + } + + private func prepareUnsignedClaim( + normalizedName: String, + fingerprint: String, + walletIndex: Int + ) async throws -> (WatchOnlyAccountRecord, Data) { + 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 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)) + } + } + + 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 + } + + 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: true + ) + 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 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 + } + } + + private var failureSetupState: WatchOnlyAccountSetupState { + preservesAuthorizingStateOnFailure ? .authorizing : .pendingDelivery + } + + private var isTrackingEnabledOnFailure: Bool { + preservesAuthorizingStateOnFailure + } + + func rename(id: UUID, name: String) async throws { + let normalizedName = try Self.normalizedName(name) + 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 { + 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 + } + } + } + + func restore( + _ records: [WatchOnlyAccountRecord]?, + allocationState: WatchOnlyAccountAllocationState? + ) async throws { + try await lifecycleCoordinator.withLock { + try WatchOnlyAccountStore.restore(records, allocationState: allocationState, defaults: defaults) + try reloadFromStore() + } + } + + 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) + } + + 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 { + 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] + } +} + +enum WatchOnlyAccountClaimCodec { + static let version: UInt8 = 1 + static let nativeSegwitAddressType: UInt8 = 0 + static let serializedXpubLength = 78 + static let payloadLength = 1 + 4 + 1 + serializedXpubLength + + static func encode(record: WatchOnlyAccountRecord) 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) + return claim + } + + static func serializedXpub(_ xpub: String) throws -> Data { + 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 + } + } +} diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift index ddc12ddfe..617cd3e4f 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) } + try await WatchOnlyAccountManager.shared.clear() // 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/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/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 5475460e9..ef132b1b1 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -701,7 +701,7 @@ 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 diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift index 661035ac8..4d243d24e 100644 --- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift +++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift @@ -5,6 +5,7 @@ struct AdvancedSettingsView: View { @EnvironmentObject private var wallet: WalletViewModel @AppStorage("showDevSettings") private var showDevSettings = Env.isDebug + @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = false private var electrumRowRightText: String { let currentServerUrl = settings.electrumCurrentServer.fullUrl @@ -63,6 +64,17 @@ struct AdvancedSettingsView: View { } .accessibilityIdentifier("AddressViewer") + if PaykitFeatureFlags.isUIAvailable, isPaykitUIEnabled { + NavigationLink(value: Route.watchOnlyAccounts) { + SettingsRow( + title: t("watch_only_accounts__title"), + iconName: "lock-key", + 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..f15daccbc --- /dev/null +++ b/Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +struct WatchOnlyAccountsView: View { + @EnvironmentObject private var app: AppViewModel + + @State private var manager = WatchOnlyAccountManager.shared + @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: 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 { + 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) + } + } + } + } + .padding(16) + .bottomSafeAreaPadding() + } + } + .background(Color.customBlack) + .navigationBarHidden(true) + .task { + do { + 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 await manager.rename(id: account.id, name: name) + } + .environmentObject(app) + } + } + + 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 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)") + } + + 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) } + } + ) + } + + @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) async throws -> Void + + @State private var name: String + + init(account: WatchOnlyAccountRecord, onRename: @escaping (String) async throws -> Void) { + self.account = account + self.onRename = onRename + _name = State(initialValue: account.name) + } + + 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 { + Task { await 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) { + Task { await saveName() } + } + .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)") + } + .padding(.bottom, 24) + } + .padding(.horizontal, 16) + .background(Color.customBlack) + .presentationDetents([.height(520)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(32) + } + + @MainActor + private func saveName() async { + do { + try await 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) + } + } +} + +#Preview { + NavigationStack { + WatchOnlyAccountsView() + .environmentObject(AppViewModel()) + } + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift index 4ef7939a7..2d79b2076 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))" +} + struct PubkyAuthApprovalConfig { let authUrl: String let request: PubkyAuthRequest @@ -46,67 +53,124 @@ struct PubkyAuthApprovalSheet: View { let config: PubkyAuthApprovalSheetItem - @State private var state: ApprovalState = .authorize + @State private var state: ApprovalState @State private var isShowingAuthCheck = false - private 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 } + self = .authorizing + return true + } + } + + 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) - - switch state { - case .authorize: - authorizeContent - case .authorizing: - authorizingContent - case .success: - successContent - } + if state == .watchOnlyConsent { + watchOnlyConsentContent + } else { + authorizationFlowContent } - .padding(.horizontal, 16) } .fullScreenCover(isPresented: $isShowingAuthCheck) { AuthCheck( onCancel: { isShowingAuthCheck = false + state = .authorize }, onPinVerified: { isShowingAuthCheck = false Task { - await confirmAuthorize() + await performAuthorization() } } ) } } - // MARK: - Authorize State (Screen 3) - - private var authorizeContent: some View { - VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 32) + // MARK: - Watch-Only Consent + + private var watchOnlyConsentContent: some View { + 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() } + ) + } - permissionsSection - .padding(.bottom, 16) + // MARK: - Authorization - Spacer() + private var authorizationFlowContent: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader( + title: headerTitle, + showBackButton: showsBackButton, + onBack: onBack + ) - trustWarning - .padding(.bottom, 16) + switch state { + case .watchOnlyConsent: + EmptyView() + case .authorize: + authorizeContent + case .authorizing: + authorizingContent + case .success: + successContent + } + } + .padding(.horizontal, 16) + } - profileCard - .padding(.bottom, 24) + private var authorizeContent: some View { + VStack(alignment: .leading, spacing: 0) { + approvalDetails HStack(spacing: 16) { CustomButton(title: t("common__cancel"), variant: .secondary) { @@ -122,30 +186,19 @@ struct PubkyAuthApprovalSheet: View { } } - // MARK: - Authorizing State (Screen 4) + // MARK: - Authorization Progress private var authorizingContent: some View { VStack(alignment: .leading, spacing: 0) { - descriptionText - .padding(.bottom, 32) + approvalDetails - permissionsSection - .padding(.bottom, 16) - - Spacer() - - trustWarning - .padding(.bottom, 16) - - profileCard - .padding(.bottom, 24) - - 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 private var successContent: some View { VStack(alignment: .leading, spacing: 0) { @@ -171,6 +224,29 @@ struct PubkyAuthApprovalSheet: View { // MARK: - Shared Components + private var approvalDetails: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + descriptionText + .padding(.bottom, 32) + + permissionsSection + + Spacer(minLength: 32) + + trustWarning + .padding(.bottom, 16) + + profileCard + .padding(.bottom, 16) + } + .frame(minHeight: geometry.size.height, alignment: .top) + } + .scrollIndicators(.hidden) + } + } + private var serviceText: String { config.request.serviceNames.joined(separator: " and ") } @@ -184,11 +260,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, @@ -215,7 +289,7 @@ struct PubkyAuthApprovalSheet: View { .font(.system(size: 14)) .foregroundColor(.white) - BodySSBText(permission.path) + BodySSBText(permission.displayPath) .lineLimit(1) Spacer() @@ -230,33 +304,32 @@ struct PubkyAuthApprovalSheet: View { } 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) @@ -266,6 +339,8 @@ struct PubkyAuthApprovalSheet: View { @MainActor private func onAuthorize() async { + guard state.beginAuthorization() else { return } + switch resolvePubkyApprovalLocalAuthMode( isPinEnabled: settings.pinEnabled, isBiometricEnabled: settings.useBiometrics, @@ -276,7 +351,7 @@ struct PubkyAuthApprovalSheet: View { case .biometrics: await authorizeWithBiometrics() case .none: - await confirmAuthorize() + await performAuthorization() } } @@ -286,18 +361,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 { - state = .authorizing - + private func performAuthorization() async { + guard state == .authorizing else { return } do { guard let secretKey = try Keychain.loadString(key: .pubkySecretKey), !secretKey.isEmpty @@ -307,9 +382,10 @@ struct PubkyAuthApprovalSheet: View { return } - try await PubkyService.approveAuth( + try await PubkyService.approveAuthRequest( + request: config.request, authUrl: config.authUrl, - expectedCapabilities: config.request.capabilities, + accountName: watchOnlyAccountName, secretKeyHex: secretKey ) @@ -320,4 +396,22 @@ struct PubkyAuthApprovalSheet: View { state = .authorize } } + + 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") + } + + 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/PaykitReceiverNoiseKeyStoreTests.swift b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift new file mode 100644 index 000000000..017b2828d --- /dev/null +++ b/BitkitTests/PaykitReceiverNoiseKeyStoreTests.swift @@ -0,0 +1,76 @@ +@testable import Bitkit +import Foundation +import Paykit +import XCTest + +final class PaykitReceiverNoiseKeyStoreTests: XCTestCase { + 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 }, + deriveBytes: { derivedBytes } + ) + + let first = try store.loadOrDerive().exportBytes() + let second = try store.loadOrDerive().exportBytes() + let restoredStore = PaykitReceiverNoiseKeyStore( + loadBytes: { persistedBytes }, + upsertBytes: { persistedBytes = $0 }, + deriveBytes: { derivedBytes } + ) + let restored = try restoredStore.loadOrDerive().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 }, + deriveBytes: { Data(repeating: 1, count: 32) } + ) + + 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") }, + 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.loadOrDerive()) + } +} diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index c4cc7bdf4..bd1c265aa 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -120,6 +120,8 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(payload.transfers.isEmpty) XCTAssertNil(payload.privatePaykitHighestReservedReceiveIndexByAddressType) XCTAssertNil(payload.paykitSdkBackupState) + XCTAssertNil(payload.watchOnlyAccounts) + XCTAssertNil(payload.watchOnlyAccountAllocationState) } func testWalletBackupRoundTripsPrivateReservationCeilingAndSdkState() throws { @@ -128,7 +130,9 @@ final class PrivatePaykitServiceTests: XCTestCase { createdAt: 123, transfers: [], privatePaykitHighestReservedReceiveIndexByAddressType: ["nativeSegwit": 5], - paykitSdkBackupState: "AQID" + paykitSdkBackupState: "AQID", + watchOnlyAccounts: nil, + watchOnlyAccountAllocationState: nil ) let data = try JSONEncoder().encode(backup) diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift index 3e8f24e80..1f67a08fe 100644 --- a/BitkitTests/PubkyAuthApprovalSheetTests.swift +++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift @@ -1,7 +1,56 @@ @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") + 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) + } + + @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, @@ -41,4 +90,410 @@ 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 PubkyService.approveAuthRequest( + 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 PubkyService.approveAuthRequest( + 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 testApprovalStateBeginsAuthorizationOnlyOnce() { + var state = PubkyAuthApprovalSheet.ApprovalState.authorize + + XCTAssertTrue(state.beginAuthorization()) + XCTAssertEqual(state, .authorizing) + XCTAssertFalse(state.beginAuthorization()) + } + + @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 PubkyService.approveAuthRequest( + 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 PubkyService.approveAuthRequest( + 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 PubkyService.approveAuthRequest( + 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)" + 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 PubkyService.approveAuthRequest( + 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 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 PubkyService.approveAuthRequest( + 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 PubkyService.approveAuthRequest( + 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 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)" + 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 PubkyService.approveAuthRequest( + 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]) + } + + @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 PubkyService.approveAuthRequest( + 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 +} + +private actor ApprovalCompanionGate { + private var count = 0 + private var firstApprovalContinuation: CheckedContinuation? + private var hasStartedFirstApproval = false + + var approvalCount: Int { + count + } + + func approve() async { + count += 1 + guard count == 1 else { return } + + hasStartedFirstApproval = true + + await withCheckedContinuation { continuation in + firstApprovalContinuation = 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() + } + } + + func releaseFirstApproval() { + firstApprovalContinuation?.resume() + firstApprovalContinuation = nil + } +} + +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 { + approvalTestXpub + } + + func setWatchOnlyAccountTracking( + accountIndex _: UInt32, + addressType _: LDKNode.AddressType, + xpub _: String, + enabled: Bool + ) async throws { + if !enabled, checkCancellationWhenDisabling { + try Task.checkCancellation() + } + trackingChanges.append(enabled) + if enabled, failNextTrackingPreparation { + failNextTrackingPreparation = false + throw ApprovalFakeError.trackingPreparationFailed + } + } + + func reconcileWatchOnlyAccountTracking( + records _: [Bitkit.WatchOnlyAccountRecord], + managedRecords _: [Bitkit.WatchOnlyAccountRecord] + ) async throws {} +} + +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 3ce3dcbde..0539982f5 100644 --- a/BitkitTests/PubkyAuthRequestTests.swift +++ b/BitkitTests/PubkyAuthRequestTests.swift @@ -3,6 +3,16 @@ 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 testProtocolUrlRecognizesPubkyAuthSchemeCaseInsensitively() { + XCTAssertTrue(PubkyAuthRequest.isProtocolURL("pubkyauth://signin?caps=/pub/bitkit.to/:rw")) + XCTAssertTrue(PubkyAuthRequest.isProtocolURL("PUBKYAUTH://signin?caps=/pub/bitkit.to/:rw")) + XCTAssertTrue(PubkyAuthRequest.isProtocolURL(" pubkyauth://signin?caps=/pub/bitkit.to/:rw\n")) + XCTAssertFalse(PubkyAuthRequest.isProtocolURL("lightning:lnbc1example")) + } + 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 +24,56 @@ 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 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, + 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() { @@ -107,7 +167,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") @@ -133,4 +203,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..3ab0460be --- /dev/null +++ b/BitkitTests/WatchOnlyAccountServiceTests.swift @@ -0,0 +1,972 @@ +@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 = testSerializedXpubHex.hexaData + let record = makeRecord(accountIndex: 42, xpub: testXpub) + + 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) + XCTAssertEqual(payload.subdata(in: 6 ..< 84), rawXpub) + + let accountIndex = payload[1 ..< 5].reduce(UInt32.zero) { ($0 << 8) | UInt32($1) } + XCTAssertEqual(accountIndex, 42) + } + + func testUnsignedClaimRejectsInvalidBase58CheckChecksum() throws { + 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) + } + } + + 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: "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: "first", + defaults: defaults + ), 3) + } + + 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 testRestoreKeepsMatchingLocalPendingReservation() 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) + } + + func testRestoreSanitizesAccountsAndNormalizesIncompleteTracking() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = testXpub + 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 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, activeDisabled, duplicateSlot, invalid], + defaults: defaults + ) + + 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 testRestoreBurnsIndexesFromDiscardedAccounts() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + try WatchOnlyAccountStore.restore( + [makeRecord(accountIndex: 8, xpub: "invalid", requestFingerprint: "invalid")], + defaults: defaults + ) + + XCTAssertTrue(try WatchOnlyAccountStore.load(defaults: defaults).isEmpty) + XCTAssertEqual(try WatchOnlyAccountStore.reserveAccountIndex( + walletIndex: 0, + requestFingerprint: "next", + defaults: defaults + ), 9) + } + + func testRestorePreservesAuthorizingAccountOverBackupState() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = testXpub + let local = makeRecord( + accountIndex: 1, + xpub: xpub, + setupState: .authorizing, + requestFingerprint: "request" + ) + let restored = makeRecord( + accountIndex: 1, + xpub: xpub, + setupState: .active, + id: local.id, + requestFingerprint: "request" + ) + try WatchOnlyAccountStore.save([local], defaults: defaults) + + try WatchOnlyAccountStore.restore([restored], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) + let snapshot = try WatchOnlyAccountStore.backupSnapshot(defaults: defaults) + XCTAssertEqual(snapshot.allocationState.pendingAccountIndexByRequest["0:request"], 1) + } + + func testRestorePreservesLocalOwnerWhenBackupReusesItsSlot() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let local = makeRecord( + accountIndex: 1, + xpub: testXpub, + setupState: .active, + requestFingerprint: "local" + ) + let conflictingBackup = makeRecord( + accountIndex: 1, + xpub: alternateTestXpub, + setupState: .active, + requestFingerprint: "restored" + ) + try WatchOnlyAccountStore.save([local], defaults: defaults) + + try WatchOnlyAccountStore.restore([conflictingBackup], defaults: defaults) + + XCTAssertEqual(try WatchOnlyAccountStore.load(defaults: defaults), [local]) + } + + func testReconciliationClearsPendingUnloadsOnlyForCurrentWallet() throws { + let suiteName = "WatchOnlyAccountServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + 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) + 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)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let xpub = testXpub + 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 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 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 await activateSetupAuthorization(manager: manager, 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(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: testXpub, + 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 + func testRenameTrackingAndActiveStatePersist() 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=state", + name: "Original" + ) + + try await manager.rename(id: prepared.0.id, name: "Creator shop") + XCTAssertFalse(try XCTUnwrap(try WatchOnlyAccountStore.load(defaults: defaults).first).isTrackingEnabled) + 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) + 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), + ] + ) + } + + @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: testXpub, + 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: testXpub, + 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: testXpub, + 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, + id: UUID = UUID(), + requestFingerprint: String = "request" + ) -> WatchOnlyAccountRecord { + WatchOnlyAccountRecord( + id: id, + walletIndex: walletIndex, + accountIndex: accountIndex, + addressType: addressType, + xpub: xpub, + requestFingerprint: requestFingerprint, + createdAt: 1000, + name: "Test", + isTrackingEnabled: isTrackingEnabled, + setupState: setupState + ) + } +} + +private struct TrackingChange: Equatable { + let accountIndex: UInt32 + let enabled: Bool +} + +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) + if creationDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: creationDelayNanoseconds) + } + if failNextCreation { + failNextCreation = false + throw FakeNodeError.creationFailed + } + return accountIndex.isMultiple(of: 2) ? alternateTestXpub : testXpub + } + + func setWatchOnlyAccountTracking( + accountIndex: UInt32, + addressType _: LDKNode.AddressType, + xpub _: String, + 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 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, + 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 new file mode 100644 index 000000000..bc043e916 --- /dev/null +++ b/Docs/watch-only-account-claim-v1.md @@ -0,0 +1,51 @@ +# 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`. +- 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. +- 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 + +Bitkit serializes this exact 84-byte unsigned payload: + +| 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 | + +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: + +```text +UTF8("x-bitkit-claim|watch-only-account-v1|") +|| 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 + +- 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))}`. +- 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 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. +- 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. diff --git a/changelog.d/next/630.added.md b/changelog.d/next/630.added.md new file mode 100644 index 000000000..6f6831858 --- /dev/null +++ b/changelog.d/next/630.added.md @@ -0,0 +1 @@ +Bitkit now creates, names, backs up, and manages separate watch-only Bitcoin accounts and securely delivers signed setup claims to Paykit servers.