From 84cd4cec7deded92256ff3a795559c2a8c5bc966 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 27 May 2026 12:14:58 -0500 Subject: [PATCH 1/7] chore: bump bitkit core to v0.1.63 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c492f2ece..e030c3138 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.63; }; }; 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..13c051607 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" : "ddc4c8d7d62fb29a24fac293d4ce953e38750b88", + "version" : "0.1.63" } }, { From 79615d7d733ebc1870064beef7ad78d35b1d68cd Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 27 May 2026 12:36:12 -0500 Subject: [PATCH 2/7] feat: add legacy RN close recovery flow --- Bitkit/MainNavView.swift | 1 + Bitkit/Services/CoreService.swift | 56 ++++ Bitkit/ViewModels/NavigationViewModel.swift | 1 + .../DevSettings/LegacyRnRecoveryScreen.swift | 315 ++++++++++++++++++ Bitkit/Views/Settings/DevSettingsView.swift | 8 + 5 files changed, 381 insertions(+) create mode 100644 Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift 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..7bd9a8531 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1916,6 +1916,62 @@ 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 + ) async throws -> LegacyRnCloseRecoveryScanResult { + try await ServiceQueue.background(.core) { + let credentials = try self.recoveryWalletCredentials(walletIndex: walletIndex) + let electrumUrl = ElectrumConfigService().getCurrentServer().fullUrl + + return try await 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 + ) async throws -> LegacyRnCloseRecoverySweepPreview { + try await ServiceQueue.background(.core) { + let credentials = try self.recoveryWalletCredentials(walletIndex: walletIndex) + let electrumUrl = ElectrumConfigService().getCurrentServer().fullUrl + + return try await prepareLegacyRnNativeSegwitRecoverySweep( + mnemonicPhrase: credentials.mnemonic, + network: Env.bitkitCoreNetwork, + electrumUrl: electrumUrl, + destinationAddress: destinationAddress, + feeRateSatsPerVbyte: feeRateSatsPerVbyte, + indexLimit: indexLimit, + bip39Passphrase: credentials.passphrase + ) + } + } + + func broadcastRawTx(txHex: String) async throws -> String { + try await ServiceQueue.background(.core) { + let electrumUrl = ElectrumConfigService().getCurrentServer().fullUrl + return try await onchainBroadcastRawTx(serializedTx: txHex, electrumUrl: electrumUrl) + } + } + func getAccountAddresses( walletIndex: Int = 0, isChange: Bool? = nil, 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..2906cfc5d --- /dev/null +++ b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift @@ -0,0 +1,315 @@ +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 + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: "Legacy RN Recovery") + .padding(.horizontal, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + scanSection + + if let errorMessage { + messageSection( + title: "Error", + message: errorMessage, + color: .redAccent + ) + } + + if let broadcastTxid { + successSection(txid: broadcastTxid) + } else if let sweepPreview { + previewSection(sweepPreview) + } else if let scanResult { + if scanResult.outputsCount == 0 { + messageSection( + title: "No Funds Found", + message: "No legacy RN native SegWit close outputs were found up to index \(indexLimit).", + color: .textPrimary + ) + } 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 React Native 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") + } + + 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") + } + } + + 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 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 + ) + + CustomButton(title: "Done", shouldExpand: true) { + dismiss() + } + } + } + + 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) + } 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 + ) + } 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) + 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..baf1ac321 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 RN Close Recovery") + .accessibilityIdentifier("LegacyRnRecovery") + } + if PaykitFeatureFlags.isUIAvailable { SettingsSectionHeader("PAYKIT") .padding(.top, 16) From 313c1db662327f8819e96b6a529928a669332a27 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 27 May 2026 15:45:45 -0500 Subject: [PATCH 3/7] fix: polish legacy rn recovery actions --- .../DevSettings/LegacyRnRecoveryScreen.swift | 130 +++++++++++++----- 1 file changed, 94 insertions(+), 36 deletions(-) diff --git a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift index 2906cfc5d..d12a1dd4d 100644 --- a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift +++ b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift @@ -29,41 +29,43 @@ struct LegacyRnRecoveryScreen: View { parsedIndexLimit != nil && !isBusy } + private var hasResult: Bool { + scanResult != nil || sweepPreview != nil || broadcastTxid != nil + } + var body: some View { VStack(alignment: .leading, spacing: 0) { NavigationBar(title: "Legacy RN Recovery") .padding(.horizontal, 16) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 24) { - scanSection - - if let errorMessage { - messageSection( - title: "Error", - message: errorMessage, - color: .redAccent - ) - } + if let broadcastTxid { + successPage(txid: broadcastTxid) + } else { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + scanSection - if let broadcastTxid { - successSection(txid: broadcastTxid) - } else if let sweepPreview { - previewSection(sweepPreview) - } else if let scanResult { - if scanResult.outputsCount == 0 { + if let errorMessage { messageSection( - title: "No Funds Found", - message: "No legacy RN native SegWit close outputs were found up to index \(indexLimit).", - color: .textPrimary + title: "Error", + message: errorMessage, + color: .redAccent ) - } else { - foundSection(scanResult) + } + + if let sweepPreview { + previewSection(sweepPreview) + } else if let scanResult { + if scanResult.outputsCount == 0 { + noFundsSection + } else { + foundSection(scanResult) + } } } + .padding(.horizontal, 16) + .bottomSafeAreaPadding() } - .padding(.horizontal, 16) - .bottomSafeAreaPadding() } } .navigationBarHidden(true) @@ -98,15 +100,17 @@ struct LegacyRnRecoveryScreen: View { FootnoteText("Index limit") } - CustomButton( - title: "Scan", - isDisabled: !canScan, - isLoading: isScanning, - shouldExpand: true - ) { - await scan() + if !hasResult { + CustomButton( + title: "Scan", + isDisabled: !canScan, + isLoading: isScanning, + shouldExpand: true + ) { + await scan() + } + .accessibilityIdentifier("LegacyRnRecoveryScan") } - .accessibilityIdentifier("LegacyRnRecoveryScan") } } @@ -136,6 +140,34 @@ struct LegacyRnRecoveryScreen: View { 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 RN native SegWit close outputs were found up to index \(indexLimit).", + color: .textPrimary + ) + + CustomButton( + title: "Scan Again", + variant: .secondary, + isDisabled: isBusy, + shouldExpand: true + ) { + await scan() + } } } @@ -192,6 +224,36 @@ struct LegacyRnRecoveryScreen: View { } } + 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") @@ -208,10 +270,6 @@ struct LegacyRnRecoveryScreen: View { "The sweep transaction was broadcast. The funds will appear after the wallet syncs and the transaction confirms.", textColor: .textSecondary ) - - CustomButton(title: "Done", shouldExpand: true) { - dismiss() - } } } From b0f935b75f205233e0ef098da84db0c140011f9a Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 28 May 2026 07:45:20 -0500 Subject: [PATCH 4/7] fix: avoid app-only electrum service in notification target --- Bitkit/Services/CoreService.swift | 15 +++++++-------- .../DevSettings/LegacyRnRecoveryScreen.swift | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 7bd9a8531..50261c3d5 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -1927,13 +1927,13 @@ class UtilityService { func scanLegacyRnNativeSegwitRecoveryFunds( walletIndex: Int = 0, - indexLimit: UInt32 + indexLimit: UInt32, + electrumUrl: String = Env.electrumServerUrl ) async throws -> LegacyRnCloseRecoveryScanResult { try await ServiceQueue.background(.core) { let credentials = try self.recoveryWalletCredentials(walletIndex: walletIndex) - let electrumUrl = ElectrumConfigService().getCurrentServer().fullUrl - return try await scanLegacyRnNativeSegwitRecoveryFunds( + return try await BitkitCore.scanLegacyRnNativeSegwitRecoveryFunds( mnemonicPhrase: credentials.mnemonic, network: Env.bitkitCoreNetwork, electrumUrl: electrumUrl, @@ -1947,13 +1947,13 @@ class UtilityService { destinationAddress: String, feeRateSatsPerVbyte: UInt32?, walletIndex: Int = 0, - indexLimit: UInt32 + indexLimit: UInt32, + electrumUrl: String = Env.electrumServerUrl ) async throws -> LegacyRnCloseRecoverySweepPreview { try await ServiceQueue.background(.core) { let credentials = try self.recoveryWalletCredentials(walletIndex: walletIndex) - let electrumUrl = ElectrumConfigService().getCurrentServer().fullUrl - return try await prepareLegacyRnNativeSegwitRecoverySweep( + return try await BitkitCore.prepareLegacyRnNativeSegwitRecoverySweep( mnemonicPhrase: credentials.mnemonic, network: Env.bitkitCoreNetwork, electrumUrl: electrumUrl, @@ -1965,9 +1965,8 @@ class UtilityService { } } - func broadcastRawTx(txHex: String) async throws -> String { + func broadcastRawTx(txHex: String, electrumUrl: String = Env.electrumServerUrl) async throws -> String { try await ServiceQueue.background(.core) { - let electrumUrl = ElectrumConfigService().getCurrentServer().fullUrl return try await onchainBroadcastRawTx(serializedTx: txHex, electrumUrl: electrumUrl) } } diff --git a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift index d12a1dd4d..d8a7d4a13 100644 --- a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift +++ b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift @@ -33,6 +33,10 @@ struct LegacyRnRecoveryScreen: View { 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 RN Recovery") @@ -295,7 +299,10 @@ struct LegacyRnRecoveryScreen: View { defer { isScanning = false } do { - scanResult = try await CoreService.shared.utility.scanLegacyRnNativeSegwitRecoveryFunds(indexLimit: limit) + scanResult = try await CoreService.shared.utility.scanLegacyRnNativeSegwitRecoveryFunds( + indexLimit: limit, + electrumUrl: currentElectrumServerUrl() + ) } catch { Logger.error(error, context: "LegacyRnRecoveryScreen.scan") errorMessage = error.localizedDescription @@ -328,7 +335,8 @@ struct LegacyRnRecoveryScreen: View { sweepPreview = try await CoreService.shared.utility.prepareLegacyRnNativeSegwitRecoverySweep( destinationAddress: wallet.onchainAddress, feeRateSatsPerVbyte: feeRate, - indexLimit: limit + indexLimit: limit, + electrumUrl: currentElectrumServerUrl() ) } catch { Logger.error(error, context: "LegacyRnRecoveryScreen.prepareSweep") @@ -345,7 +353,10 @@ struct LegacyRnRecoveryScreen: View { defer { isBroadcasting = false } do { - let txid = try await CoreService.shared.utility.broadcastRawTx(txHex: sweepPreview.txHex) + 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)) From 353ffb14d0169697312dfda530df720ae83ceaf7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 28 May 2026 14:30:36 -0500 Subject: [PATCH 5/7] Remove RN wording from legacy recovery UI --- .../Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift | 6 +++--- Bitkit/Views/Settings/DevSettingsView.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift index d8a7d4a13..278b0f518 100644 --- a/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift +++ b/Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift @@ -39,7 +39,7 @@ struct LegacyRnRecoveryScreen: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: "Legacy RN Recovery") + NavigationBar(title: "Legacy Recovery") .padding(.horizontal, 16) if let broadcastTxid { @@ -87,7 +87,7 @@ struct LegacyRnRecoveryScreen: View { SettingsSectionHeader("SCAN") BodySText( - "Scan for native SegWit outputs generated by the legacy React Native channel-close path.", + "Scan for native SegWit outputs generated by the legacy channel-close path.", textColor: .textSecondary ) @@ -160,7 +160,7 @@ struct LegacyRnRecoveryScreen: View { VStack(alignment: .leading, spacing: 12) { messageSection( title: "No Funds Found", - message: "No legacy RN native SegWit close outputs were found up to index \(indexLimit).", + message: "No legacy native SegWit close outputs were found up to index \(indexLimit).", color: .textPrimary ) diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index baf1ac321..9c6542d61 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -57,7 +57,7 @@ struct DevSettingsView: View { .padding(.top, 16) NavigationLink(value: Route.legacyRnRecovery) { - SettingsRow(title: "Legacy RN Close Recovery") + SettingsRow(title: "Legacy Close Recovery") .accessibilityIdentifier("LegacyRnRecovery") } From f20ca7049395f875d4cde1643d4f38ffdee0f0a2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 28 May 2026 14:39:13 -0500 Subject: [PATCH 6/7] Add legacy recovery changelog --- changelog.d/next/572.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/572.added.md 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. From 081e42362c206face654c9d13a304b8f2ceab69d Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 29 May 2026 07:57:48 -0500 Subject: [PATCH 7/7] Bump bitkit core to v0.1.64 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Bitkit/Services/Trezor/TrezorService.swift | 2 +- Bitkit/Services/Trezor/TrezorUiHandler.swift | 20 +++++++++++++------ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index e030c3138..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.63; + 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 13c051607..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" : "ddc4c8d7d62fb29a24fac293d4ce953e38750b88", - "version" : "0.1.63" + "revision" : "a7577cc4572d581a0ab1d84f2792a1e6198110ef", + "version" : "0.1.64" } }, { 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() }