diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c492f2ece..085103027 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1200,7 +1200,7 @@ repositoryURL = "https://github.com/synonymdev/bitkit-core"; requirement = { kind = exactVersion; - version = 0.1.58; + version = 0.1.64; }; }; 96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2be8042f8..dafce08c6 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" : "47bd506bb46ae885191a265f76245ab357a93f28", - "version" : "0.1.58" + "revision" : "a7577cc4572d581a0ab1d84f2792a1e6198110ef", + "version" : "0.1.64" } }, { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index a89f282a8..b13b8e12c 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -538,6 +538,7 @@ struct MainNavView: View { case .ldkDebug: LdkDebugScreen() case .vssDebug: VssDebugScreen() case .probingTool: ProbingToolScreen() + case .legacyRnRecovery: LegacyRnRecoveryScreen() case .orders: ChannelOrders() case .logs: LogView() case .trezor: TrezorRootView() diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index c1001b8fd..50261c3d5 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1916,6 +1916,61 @@ class UtilityService { self.coreService = coreService } + private func recoveryWalletCredentials(walletIndex: Int) throws -> (mnemonic: String, passphrase: String?) { + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw AppError(message: "Mnemonic not found", debugMessage: "Unable to load mnemonic for wallet index \(walletIndex)") + } + + let passphrase = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + return (mnemonic, passphrase) + } + + func scanLegacyRnNativeSegwitRecoveryFunds( + walletIndex: Int = 0, + indexLimit: UInt32, + electrumUrl: String = Env.electrumServerUrl + ) async throws -> LegacyRnCloseRecoveryScanResult { + try await ServiceQueue.background(.core) { + let credentials = try self.recoveryWalletCredentials(walletIndex: walletIndex) + + return try await BitkitCore.scanLegacyRnNativeSegwitRecoveryFunds( + mnemonicPhrase: credentials.mnemonic, + network: Env.bitkitCoreNetwork, + electrumUrl: electrumUrl, + indexLimit: indexLimit, + bip39Passphrase: credentials.passphrase + ) + } + } + + func prepareLegacyRnNativeSegwitRecoverySweep( + destinationAddress: String, + feeRateSatsPerVbyte: UInt32?, + walletIndex: Int = 0, + indexLimit: UInt32, + electrumUrl: String = Env.electrumServerUrl + ) async throws -> LegacyRnCloseRecoverySweepPreview { + try await ServiceQueue.background(.core) { + let credentials = try self.recoveryWalletCredentials(walletIndex: walletIndex) + + return try await BitkitCore.prepareLegacyRnNativeSegwitRecoverySweep( + mnemonicPhrase: credentials.mnemonic, + network: Env.bitkitCoreNetwork, + electrumUrl: electrumUrl, + destinationAddress: destinationAddress, + feeRateSatsPerVbyte: feeRateSatsPerVbyte, + indexLimit: indexLimit, + bip39Passphrase: credentials.passphrase + ) + } + } + + func broadcastRawTx(txHex: String, electrumUrl: String = Env.electrumServerUrl) async throws -> String { + try await ServiceQueue.background(.core) { + return try await onchainBroadcastRawTx(serializedTx: txHex, electrumUrl: electrumUrl) + } + } + func getAccountAddresses( walletIndex: Int = 0, isChange: Bool? = nil, diff --git a/Bitkit/Services/Trezor/TrezorService.swift b/Bitkit/Services/Trezor/TrezorService.swift index 127b3150a..d5dbf74b9 100644 --- a/Bitkit/Services/Trezor/TrezorService.swift +++ b/Bitkit/Services/Trezor/TrezorService.swift @@ -71,7 +71,7 @@ class TrezorService { func connect(deviceId: String) async throws -> TrezorFeatures { try await ServiceQueue.background(.core) { [self] in ensureCallbacksRegistered() - return try await trezorConnect(deviceId: deviceId) + return try await trezorConnect(deviceId: deviceId, selection: .standard) } } diff --git a/Bitkit/Services/Trezor/TrezorUiHandler.swift b/Bitkit/Services/Trezor/TrezorUiHandler.swift index ebeacb311..a698e79c6 100644 --- a/Bitkit/Services/Trezor/TrezorUiHandler.swift +++ b/Bitkit/Services/Trezor/TrezorUiHandler.swift @@ -24,6 +24,7 @@ final class TrezorUiHandler: TrezorUiCallback { let needsPassphrasePublisher = PassthroughSubject() private var submittedPassphrase: String = "" + private var didCancelPassphrase = false private let passphraseLock = NSLock() private let passphraseSemaphore = DispatchSemaphore(value: 0) @@ -75,11 +76,12 @@ final class TrezorUiHandler: TrezorUiCallback { return pin } - func onPassphraseRequest(onDevice: Bool) -> String { + func onPassphraseRequest(onDevice: Bool) -> PassphraseResponse { debugLog("onPassphraseRequest: onDevice=\(onDevice), waiting for user input...") passphraseLock.lock() submittedPassphrase = "" + didCancelPassphrase = false passphraseLock.unlock() awaitingLock.lock() @@ -101,21 +103,26 @@ final class TrezorUiHandler: TrezorUiCallback { if result == .timedOut { debugLog("onPassphraseRequest: timed out") - return "" + return .cancel } if onDevice { - // For on-device entry, return any non-empty string to acknowledge debugLog("onPassphraseRequest(onDevice): acknowledged") - return "ok" + return .onDevice } passphraseLock.lock() let passphrase = submittedPassphrase + let wasCancelled = didCancelPassphrase passphraseLock.unlock() - debugLog("onPassphraseRequest: \(passphrase.isEmpty ? "cancelled" : "received")") - return passphrase + if wasCancelled { + debugLog("onPassphraseRequest: cancelled") + return .cancel + } + + debugLog("onPassphraseRequest: \(passphrase.isEmpty ? "standard wallet" : "received")") + return passphrase.isEmpty ? .standard : .hidden(value: passphrase) } // MARK: - UI Submit/Cancel Methods @@ -152,6 +159,7 @@ final class TrezorUiHandler: TrezorUiCallback { debugLog("cancelPassphrase") passphraseLock.lock() submittedPassphrase = "" + didCancelPassphrase = true passphraseLock.unlock() passphraseSemaphore.signal() } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index b1b416c46..e13c3350d 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -101,6 +101,7 @@ enum Route: Hashable { case ldkDebug case vssDebug case probingTool + case legacyRnRecovery case orders case logs case trezor diff --git a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift new file mode 100644 index 000000000..278b0f518 --- /dev/null +++ b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift @@ -0,0 +1,384 @@ +import BitkitCore +import SwiftUI + +struct LegacyRnRecoveryScreen: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var wallet: WalletViewModel + + @State private var indexLimit = "10000" + @State private var scanResult: LegacyRnCloseRecoveryScanResult? + @State private var sweepPreview: LegacyRnCloseRecoverySweepPreview? + @State private var broadcastTxid: String? + @State private var errorMessage: String? + @State private var isScanning = false + @State private var isPreparing = false + @State private var isBroadcasting = false + + private var parsedIndexLimit: UInt32? { + let trimmed = indexLimit.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = UInt32(trimmed), value > 0 else { return nil } + return value + } + + private var isBusy: Bool { + isScanning || isPreparing || isBroadcasting + } + + private var canScan: Bool { + parsedIndexLimit != nil && !isBusy + } + + private var hasResult: Bool { + scanResult != nil || sweepPreview != nil || broadcastTxid != nil + } + + private func currentElectrumServerUrl() -> String { + ElectrumConfigService().getCurrentServer().fullUrl + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: "Legacy Recovery") + .padding(.horizontal, 16) + + if let broadcastTxid { + successPage(txid: broadcastTxid) + } else { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + scanSection + + if let errorMessage { + messageSection( + title: "Error", + message: errorMessage, + color: .redAccent + ) + } + + if let sweepPreview { + previewSection(sweepPreview) + } else if let scanResult { + if scanResult.outputsCount == 0 { + noFundsSection + } else { + foundSection(scanResult) + } + } + } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + } + } + } + .navigationBarHidden(true) + .onChange(of: indexLimit) { _, newValue in + let filtered = newValue.filter(\.isNumber) + if filtered != newValue { + indexLimit = filtered + } + } + } + + private var scanSection: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + SettingsSectionHeader("SCAN") + + BodySText( + "Scan for native SegWit outputs generated by the legacy channel-close path.", + textColor: .textSecondary + ) + + TextField("10000", text: $indexLimit) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(.horizontal, 16) + .frame(height: 50) + .background(Color.white08) + .cornerRadius(8) + .accessibilityIdentifier("LegacyRnRecoveryIndexLimit") + + FootnoteText("Index limit") + } + + if !hasResult { + CustomButton( + title: "Scan", + isDisabled: !canScan, + isLoading: isScanning, + shouldExpand: true + ) { + await scan() + } + .accessibilityIdentifier("LegacyRnRecoveryScan") + } + } + } + + private func foundSection(_ result: LegacyRnCloseRecoveryScanResult) -> some View { + VStack(alignment: .leading, spacing: 12) { + SettingsSectionHeader("FUNDS FOUND") + + VStack(spacing: 0) { + SettingsRow( + title: "Total", + rightText: formattedSats(result.totalAmount), + rightIcon: nil + ) + SettingsRow( + title: "Outputs", + rightText: "\(result.outputsCount)", + rightIcon: nil + ) + } + + CustomButton( + title: "Prepare Sweep", + isDisabled: isBusy, + isLoading: isPreparing, + shouldExpand: true + ) { + await prepareSweep() + } + .accessibilityIdentifier("LegacyRnRecoveryPrepare") + + CustomButton( + title: "Scan Again", + variant: .secondary, + isDisabled: isBusy, + shouldExpand: true + ) { + await scan() + } + } + } + + private var noFundsSection: some View { + VStack(alignment: .leading, spacing: 12) { + messageSection( + title: "No Funds Found", + message: "No legacy native SegWit close outputs were found up to index \(indexLimit).", + color: .textPrimary + ) + + CustomButton( + title: "Scan Again", + variant: .secondary, + isDisabled: isBusy, + shouldExpand: true + ) { + await scan() + } + } + } + + private func previewSection(_ preview: LegacyRnCloseRecoverySweepPreview) -> some View { + VStack(alignment: .leading, spacing: 12) { + SettingsSectionHeader("CONFIRM SWEEP") + + VStack(spacing: 0) { + SettingsRow( + title: "Receive", + rightText: formattedSats(preview.amountAfterFees), + rightIcon: nil + ) + SettingsRow( + title: "Network Fee", + rightText: formattedSats(preview.estimatedFee), + rightIcon: nil + ) + SettingsRow( + title: "Inputs", + rightText: "\(preview.outputsCount)", + rightIcon: nil + ) + SettingsRow( + title: "To", + rightText: shortened(preview.destinationAddress), + rightIcon: nil + ) + SettingsRow( + title: "Tx", + rightText: shortened(preview.txid), + rightIcon: nil + ) + } + + CustomButton( + title: "Broadcast Sweep", + isDisabled: isBusy, + isLoading: isBroadcasting, + shouldExpand: true + ) { + await broadcastSweep() + } + .accessibilityIdentifier("LegacyRnRecoveryBroadcast") + + CustomButton( + title: "Scan Again", + variant: .secondary, + isDisabled: isBusy, + shouldExpand: true + ) { + await scan() + } + } + } + + private func successPage(txid: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + scanSection + + if let errorMessage { + messageSection( + title: "Error", + message: errorMessage, + color: .redAccent + ) + } + + successSection(txid: txid) + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + + CustomButton(title: "Done", shouldExpand: true) { + dismiss() + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + .bottomSafeAreaPadding() + .frame(maxHeight: .infinity, alignment: .top) + } + + private func successSection(txid: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + SettingsSectionHeader("SWEEP COMPLETE") + + VStack(spacing: 0) { + SettingsRow( + title: "Tx", + rightText: shortened(txid), + rightIcon: nil + ) + } + + BodySText( + "The sweep transaction was broadcast. The funds will appear after the wallet syncs and the transaction confirms.", + textColor: .textSecondary + ) + } + } + + private func messageSection(title: String, message: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 8) { + SettingsSectionHeader(title.localizedUppercase) + BodySText(message, textColor: color) + } + } + + @MainActor + private func scan() async { + guard let limit = parsedIndexLimit else { + errorMessage = "Enter a valid index limit." + return + } + + isScanning = true + errorMessage = nil + scanResult = nil + sweepPreview = nil + broadcastTxid = nil + defer { isScanning = false } + + do { + scanResult = try await CoreService.shared.utility.scanLegacyRnNativeSegwitRecoveryFunds( + indexLimit: limit, + electrumUrl: currentElectrumServerUrl() + ) + } catch { + Logger.error(error, context: "LegacyRnRecoveryScreen.scan") + errorMessage = error.localizedDescription + } + } + + @MainActor + private func prepareSweep() async { + guard let limit = parsedIndexLimit else { + errorMessage = "Enter a valid index limit." + return + } + + isPreparing = true + errorMessage = nil + sweepPreview = nil + defer { isPreparing = false } + + do { + if wallet.onchainAddress.isEmpty { + try await wallet.refreshBip21() + } + guard !wallet.onchainAddress.isEmpty else { + throw AppError(message: "Destination address unavailable", debugMessage: nil) + } + + let feeRate = (try? await CoreService.shared.blocktank.fees(refresh: false)) + .map { TransactionSpeed.normal.getFeeRate(from: $0) } + + sweepPreview = try await CoreService.shared.utility.prepareLegacyRnNativeSegwitRecoverySweep( + destinationAddress: wallet.onchainAddress, + feeRateSatsPerVbyte: feeRate, + indexLimit: limit, + electrumUrl: currentElectrumServerUrl() + ) + } catch { + Logger.error(error, context: "LegacyRnRecoveryScreen.prepareSweep") + errorMessage = error.localizedDescription + } + } + + @MainActor + private func broadcastSweep() async { + guard let sweepPreview else { return } + + isBroadcasting = true + errorMessage = nil + defer { isBroadcasting = false } + + do { + let txid = try await CoreService.shared.utility.broadcastRawTx( + txHex: sweepPreview.txHex, + electrumUrl: currentElectrumServerUrl() + ) + broadcastTxid = txid + await wallet.syncStateAsync() + app.toast(type: .success, title: "Sweep Broadcast", description: shortened(txid)) + } catch { + Logger.error(error, context: "LegacyRnRecoveryScreen.broadcastSweep") + errorMessage = error.localizedDescription + } + } + + private func formattedSats(_ sats: UInt64) -> String { + "\(CurrencyFormatter.formatSats(sats)) sats" + } + + private func shortened(_ value: String) -> String { + guard value.count > 24 else { return value } + return "\(value.prefix(10))...\(value.suffix(10))" + } +} + +#Preview { + LegacyRnRecoveryScreen() + .environmentObject(AppViewModel()) + .environmentObject(WalletViewModel()) + .preferredColorScheme(.dark) +} diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 1bf95277a..9c6542d61 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -53,6 +53,14 @@ struct DevSettingsView: View { SettingsRow(title: "Orders") } + SettingsSectionHeader("RECOVERY") + .padding(.top, 16) + + NavigationLink(value: Route.legacyRnRecovery) { + SettingsRow(title: "Legacy Close Recovery") + .accessibilityIdentifier("LegacyRnRecovery") + } + if PaykitFeatureFlags.isUIAvailable { SettingsSectionHeader("PAYKIT") .padding(.top, 16) diff --git a/changelog.d/next/572.added.md b/changelog.d/next/572.added.md new file mode 100644 index 000000000..d87358cbd --- /dev/null +++ b/changelog.d/next/572.added.md @@ -0,0 +1 @@ +Added a Legacy Recovery option in developer settings to help recover funds from affected legacy channel closes.