diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 5a953ed8..ff15e3a2 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -7,7 +7,7 @@
"location" : "https://github.com/synonymdev/bitkit-core",
"state" : {
"branch" : "master",
- "revision" : "539c48daa059bb9bb46bb00f5eb8e227021020d0"
+ "revision" : "31ebadcbc1fcbfcdd6e1f66e28b6208bc64a55f8"
}
},
{
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 032c2f93..02344467 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -81,6 +81,7 @@ struct AppScene: View {
if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled {
isPinVerified = false
}
+ SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets)
}
}
.environmentObject(app)
@@ -142,28 +143,49 @@ struct AppScene: View {
@ViewBuilder
private var migrationLoadingContent: some View {
- VStack(spacing: 24) {
- Spacer()
-
- ProgressView()
- .scaleEffect(1.5)
- .tint(.white)
-
- VStack(spacing: 8) {
- Text("Updating Wallet")
- .font(.system(size: 24, weight: .semibold))
- .foregroundColor(.white)
-
- Text("Please wait while we update the app...")
- .font(.system(size: 16))
- .foregroundColor(.white.opacity(0.7))
- .multilineTextAlignment(.center)
- }
+ VStack(spacing: 0) {
+ NavigationBar(title: t("migration__title"), showBackButton: false, showMenuButton: false)
+
+ VStack(spacing: 0) {
+ VStack {
+ Spacer()
+
+ Image("wallet")
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: .infinity)
+ .aspectRatio(1, contentMode: .fit)
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ .frame(maxHeight: .infinity)
+ .layoutPriority(1)
+
+ VStack(alignment: .leading, spacing: 14) {
+ DisplayText(t("migration__headline"))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ BodyMText(t("migration__description"))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ }
- Spacer()
+ ActivityIndicator(size: 32)
+ .padding(.top, 32)
+ }
+ .padding(.horizontal, 16)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.black)
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ .background(Color.customBlack)
+ .onAppear {
+ UIApplication.shared.isIdleTimerDisabled = true
+ }
+ .onDisappear {
+ UIApplication.shared.isIdleTimerDisabled = false
+ }
}
@ViewBuilder
diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/Contents.json
new file mode 100644
index 00000000..f1537848
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "magnifying glass 1.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "magnifying glass 1@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "magnifying glass 1@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1.png b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1.png
new file mode 100644
index 00000000..27ab88b5
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@2x.png b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@2x.png
new file mode 100644
index 00000000..6669f9b9
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@2x.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@3x.png b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@3x.png
new file mode 100644
index 00000000..6fa42150
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/magnifying-glass-illustration.imageset/magnifying glass 1@3x.png differ
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index 5320ecf9..f88b0baf 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -10,6 +10,8 @@ struct MainNavView: View {
@EnvironmentObject private var wallet: WalletViewModel
@Environment(\.scenePhase) var scenePhase
+ @StateObject private var sweepViewModel = SweepViewModel()
+
@State private var showClipboardAlert = false
@State private var clipboardUri: String?
@@ -154,6 +156,14 @@ struct MainNavView: View {
) {
config in ForceTransferSheet(config: config)
}
+ .sheet(
+ item: $sheets.sweepPromptSheetItem,
+ onDismiss: {
+ sheets.hideSheet()
+ }
+ ) {
+ config in SweepPromptSheet(config: config)
+ }
.accentColor(.white)
.overlay {
TabBar()
@@ -386,6 +396,11 @@ struct MainNavView: View {
case .electrumSettings: ElectrumSettingsScreen()
case .rgsSettings: RgsSettingsScreen()
case .addressViewer: AddressViewer()
+ case .sweep: SweepSettingsView().environmentObject(sweepViewModel)
+ case .sweepConfirm: SweepConfirmView().environmentObject(sweepViewModel)
+ case .sweepFeeRate: SweepFeeRateView().environmentObject(sweepViewModel)
+ case .sweepFeeCustom: SweepFeeCustomView().environmentObject(sweepViewModel)
+ case let .sweepSuccess(txid): SweepSuccessView(txid: txid).environmentObject(sweepViewModel)
// Dev settings
case .blocktankRegtest: BlocktankRegtestView()
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 57233572..78a63393 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -53,6 +53,7 @@
"common__delete" = "Delete";
"common__delete_yes" = "Yes, Delete";
"common__ok" = "OK";
+"common__total" = "Total";
"common__ok_random" = "Awesome!\nNice!\nCool!\nGreat!\nFantastic!\nSweet!\nExcellent!\nTerrific!";
"common__reset" = "Reset";
"common__retry" = "Retry";
@@ -725,7 +726,39 @@
"settings__adv__pp_contacts" = "Pay to/from contacts";
"settings__adv__pp_contacts_switch" = "Enable payments with contacts*";
"settings__adv__address_viewer" = "Address Viewer";
+"settings__adv__sweep_funds" = "Sweep Funds";
"settings__adv__rescan" = "Rescan Addresses";
+"sweep__title" = "Sweep Funds";
+"sweep__found_title" = "Found Funds";
+"sweep__loading_description" = "Please wait while Bitkit looks for funds in unsupported addresses (Legacy, Nested SegWit, and Taproot).";
+"sweep__looking_for_funds" = "LOOKING FOR FUNDS...";
+"sweep__found_description" = "Bitkit found funds in unsupported addresses (Legacy, Nested SegWit, and Taproot).";
+"sweep__funds_found" = "FUNDS FOUND";
+"sweep__sweep_to_wallet" = "Sweep To Wallet";
+"sweep__no_funds_title" = "No Funds To Sweep";
+"sweep__no_funds_description" = "Bitkit checked unsupported address types and found no funds to sweep.";
+"sweep__error_title" = "Error";
+"sweep__error_destination_address" = "Failed to get destination address";
+"sweep__error_fee_rate_not_set" = "Fee rate not set";
+"sweep__confirm_title" = "Confirm Sweep";
+"sweep__amount" = "Amount to Receive";
+"sweep__destination" = "Destination";
+"sweep__loading_address" = "Getting address...";
+"sweep__preparing" = "Preparing transaction...";
+"sweep__broadcasting" = "Broadcasting...";
+"sweep__swipe" = "Swipe to Sweep";
+"sweep__fee_total" = "{fee} sats total fee";
+"sweep__complete_title" = "Sweep Complete";
+"sweep__complete_description" = "Your funds have been swept and will be added to your wallet balance.";
+"sweep__wallet_overview" = "Wallet Overview";
+"sweep__view_details" = "View Details";
+"sweep__prompt_title" = "Sweep Funds";
+"sweep__prompt_headline" = "SWEEP OLD\nBITKIT FUNDS";
+"sweep__prompt_description" = "Bitkit found funds on unsupported Bitcoin address types. Sweep to move the funds to your new wallet balance.";
+"sweep__prompt_sweep" = "Sweep";
+"migration__title" = "Wallet Migration";
+"migration__headline" = "MIGRATING\nWALLET";
+"migration__description" = "Please wait while your old wallet data migrates to this new Bitkit version...";
"settings__adv__suggestions_reset" = "Reset Suggestions";
"settings__adv__reset_title" = "Reset Suggestions?";
"settings__adv__reset_desc" = "Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview.";
diff --git a/Bitkit/Utilities/BiometricAuth.swift b/Bitkit/Utilities/BiometricAuth.swift
new file mode 100644
index 00000000..4fed181d
--- /dev/null
+++ b/Bitkit/Utilities/BiometricAuth.swift
@@ -0,0 +1,87 @@
+import LocalAuthentication
+
+/// Result of a biometric authentication attempt
+enum BiometricAuthResult {
+ case success
+ case cancelled
+ case failed(message: String)
+}
+
+/// Utility for biometric authentication (Face ID / Touch ID)
+enum BiometricAuth {
+ /// The display name for the current biometry type
+ static var biometryTypeName: String {
+ switch Env.biometryType {
+ case .touchID:
+ return t("security__bio_touch_id")
+ case .faceID:
+ return t("security__bio_face_id")
+ default:
+ return t("security__bio_face_id")
+ }
+ }
+
+ /// Whether biometric authentication is available on this device
+ static var isAvailable: Bool {
+ let context = LAContext()
+ var error: NSError?
+ return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
+ }
+
+ /// Authenticate using biometrics (Face ID or Touch ID)
+ /// - Returns: Result indicating success, cancellation, or failure with error message
+ @MainActor
+ static func authenticate() async -> BiometricAuthResult {
+ await withCheckedContinuation { continuation in
+ let context = LAContext()
+ var error: NSError?
+
+ guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
+ let message = errorMessage(for: error)
+ if let message {
+ continuation.resume(returning: .failed(message: message))
+ } else {
+ continuation.resume(returning: .cancelled)
+ }
+ return
+ }
+
+ let reason = t("security__bio_confirm", variables: ["biometricsName": biometryTypeName])
+
+ context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authError in
+ DispatchQueue.main.async {
+ if success {
+ Logger.debug("Biometric authentication successful", context: "BiometricAuth")
+ continuation.resume(returning: .success)
+ } else if let authError {
+ let message = errorMessage(for: authError)
+ if let message {
+ continuation.resume(returning: .failed(message: message))
+ } else {
+ continuation.resume(returning: .cancelled)
+ }
+ } else {
+ continuation.resume(returning: .cancelled)
+ }
+ }
+ }
+ }
+ }
+
+ /// Convert LAError to user-facing error message
+ /// Returns nil for user-initiated cancellations (no error to show)
+ private static func errorMessage(for error: Error?) -> String? {
+ guard let error else { return nil }
+
+ let nsError = error as NSError
+
+ switch nsError.code {
+ case LAError.biometryNotAvailable.rawValue, LAError.biometryNotEnrolled.rawValue:
+ return t("security__bio_not_available")
+ case LAError.userCancel.rawValue, LAError.userFallback.rawValue:
+ return nil
+ default:
+ return t("security__bio_error_message", variables: ["type": biometryTypeName])
+ }
+ }
+}
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index a3b878af..a7a940f0 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -564,7 +564,6 @@ extension AppViewModel {
SettingsViewModel.shared.updatePinEnabledState()
MigrationsService.shared.isShowingMigrationLoading = false
- self.toast(type: .success, title: "Migration Complete", description: "Your wallet has been successfully migrated")
}
} else if MigrationsService.shared.isRestoringFromRNRemoteBackup {
Task {
diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift
index 37fdf469..ceeaafec 100644
--- a/Bitkit/ViewModels/NavigationViewModel.swift
+++ b/Bitkit/ViewModels/NavigationViewModel.swift
@@ -83,6 +83,11 @@ enum Route: Hashable {
case electrumSettings
case rgsSettings
case addressViewer
+ case sweep
+ case sweepConfirm
+ case sweepFeeRate
+ case sweepFeeCustom
+ case sweepSuccess(txid: String)
// Support settings
case reportIssue
diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift
index 529b76b5..7e55520b 100644
--- a/Bitkit/ViewModels/SheetViewModel.swift
+++ b/Bitkit/ViewModels/SheetViewModel.swift
@@ -18,6 +18,7 @@ enum SheetID: String, CaseIterable {
case scanner
case security
case send
+ case sweepPrompt
case tagFilter
case dateRangeSelector
}
@@ -322,4 +323,16 @@ class SheetViewModel: ObservableObject {
}
}
}
+
+ var sweepPromptSheetItem: SweepPromptSheetItem? {
+ get {
+ guard let config = activeSheetConfiguration, config.id == .sweepPrompt else { return nil }
+ return SweepPromptSheetItem()
+ }
+ set {
+ if newValue == nil {
+ activeSheetConfiguration = nil
+ }
+ }
+ }
}
diff --git a/Bitkit/ViewModels/SweepViewModel.swift b/Bitkit/ViewModels/SweepViewModel.swift
new file mode 100644
index 00000000..6165a340
--- /dev/null
+++ b/Bitkit/ViewModels/SweepViewModel.swift
@@ -0,0 +1,323 @@
+import BitkitCore
+import Foundation
+import SwiftUI
+
+/// Manages sweep transaction state and operations
+@MainActor
+class SweepViewModel: ObservableObject {
+ // MARK: - Published State
+
+ /// Current state of the sweep check
+ @Published var checkState: CheckState = .idle
+
+ /// Sweepable balances from external wallets
+ @Published var sweepableBalances: SweepableBalances?
+
+ /// Transaction preview after preparation
+ @Published var transactionPreview: SweepTransactionPreview?
+
+ /// Selected fee rate in sats/vbyte
+ @Published var selectedFeeRate: UInt32?
+
+ /// Available fee rates
+ @Published var feeRates: FeeRates?
+
+ /// Selected transaction speed
+ @Published var selectedSpeed: TransactionSpeed = .normal
+
+ /// Error message to display
+ @Published var errorMessage: String?
+
+ /// Result after broadcast
+ @Published var sweepResult: SweepResult?
+
+ /// Destination address for the sweep
+ @Published var destinationAddress: String?
+
+ /// Whether a transaction is currently being prepared
+ @Published var isPreparingTransaction = false
+
+ // MARK: - Types
+
+ enum CheckState {
+ case idle
+ case checking
+ case found(balance: UInt64)
+ case noFunds
+ case error(String)
+ }
+
+ enum SweepState {
+ case idle
+ case preparing
+ case ready
+ case broadcasting
+ case success(SweepResult)
+ case error(String)
+
+ var isLoading: Bool {
+ switch self {
+ case .idle, .preparing:
+ return true
+ default:
+ return false
+ }
+ }
+ }
+
+ @Published var sweepState: SweepState = .idle
+
+ // MARK: - Private Properties
+
+ private let walletIndex: Int
+
+ // MARK: - Computed Properties
+
+ var totalBalance: UInt64 {
+ sweepableBalances?.totalBalance ?? 0
+ }
+
+ var hasBalance: Bool {
+ totalBalance > 0
+ }
+
+ var estimatedFee: UInt64 {
+ transactionPreview?.estimatedFee ?? 0
+ }
+
+ var amountAfterFees: UInt64 {
+ transactionPreview?.amountAfterFees ?? 0
+ }
+
+ var utxosCount: UInt32 {
+ sweepableBalances?.totalUtxosCount ?? 0
+ }
+
+ // MARK: - Initialization
+
+ init(walletIndex: Int = 0) {
+ self.walletIndex = walletIndex
+ }
+
+ // MARK: - Public Methods
+
+ /// Check for sweepable balances from external addresses
+ func checkBalance() async {
+ checkState = .checking
+ errorMessage = nil
+
+ do {
+ let mnemonic = try getMnemonic()
+ let passphrase = try getPassphrase()
+ let electrumUrl = Self.getElectrumUrl()
+ let network = Env.bitkitCoreNetwork
+
+ let balances = try await BitkitCore.checkSweepableBalances(
+ mnemonicPhrase: mnemonic,
+ network: network,
+ bip39Passphrase: passphrase,
+ electrumUrl: electrumUrl
+ )
+
+ sweepableBalances = balances
+
+ if balances.totalBalance > 0 {
+ checkState = .found(balance: balances.totalBalance)
+ } else {
+ checkState = .noFunds
+ }
+ } catch {
+ Logger.error("Failed to check sweepable balance: \(error)", context: "SweepViewModel")
+ checkState = .error(error.localizedDescription)
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Prepare the sweep transaction
+ func prepareSweep(destinationAddress: String) async {
+ self.destinationAddress = destinationAddress
+ sweepState = .preparing
+ isPreparingTransaction = true
+ errorMessage = nil
+
+ guard let selectedFeeRate, selectedFeeRate > 0 else {
+ let error = t("sweep__error_fee_rate_not_set")
+ sweepState = .error(error)
+ errorMessage = error
+ isPreparingTransaction = false
+ return
+ }
+
+ do {
+ let mnemonic = try getMnemonic()
+ let passphrase = try getPassphrase()
+ let electrumUrl = Self.getElectrumUrl()
+ let network = Env.bitkitCoreNetwork
+
+ let preview = try await BitkitCore.prepareSweepTransaction(
+ mnemonicPhrase: mnemonic,
+ network: network,
+ bip39Passphrase: passphrase,
+ electrumUrl: electrumUrl,
+ destinationAddress: destinationAddress,
+ feeRateSatsPerVbyte: selectedFeeRate
+ )
+
+ transactionPreview = preview
+ sweepState = .ready
+ } catch {
+ Logger.error("Failed to prepare sweep: \(error)", context: "SweepViewModel")
+ sweepState = .error(error.localizedDescription)
+ errorMessage = error.localizedDescription
+ }
+
+ isPreparingTransaction = false
+ }
+
+ /// Broadcast the sweep transaction
+ func broadcastSweep() async {
+ guard let preview = transactionPreview else {
+ sweepState = .error("No transaction prepared")
+ return
+ }
+
+ sweepState = .broadcasting
+ errorMessage = nil
+
+ do {
+ let mnemonic = try getMnemonic()
+ let passphrase = try getPassphrase()
+ let electrumUrl = Self.getElectrumUrl()
+ let network = Env.bitkitCoreNetwork
+
+ let result = try await BitkitCore.broadcastSweepTransaction(
+ psbt: preview.psbt,
+ mnemonicPhrase: mnemonic,
+ network: network,
+ bip39Passphrase: passphrase,
+ electrumUrl: electrumUrl
+ )
+
+ sweepResult = result
+ sweepState = .success(result)
+ } catch {
+ Logger.error("Failed to broadcast sweep: \(error)", context: "SweepViewModel")
+ sweepState = .error(error.localizedDescription)
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ /// Set fee rate based on selected speed
+ func setFeeRate(speed: TransactionSpeed) async {
+ selectedSpeed = speed
+
+ switch speed {
+ case let .custom(rate):
+ selectedFeeRate = rate
+ default:
+ if let rates = feeRates {
+ selectedFeeRate = speed.getFeeRate(from: rates)
+ }
+ }
+ }
+
+ /// Load current fee estimates
+ func loadFeeEstimates() async throws {
+ var rates = try? await CoreService.shared.blocktank.fees(refresh: true)
+
+ if rates == nil {
+ Logger.warn("Failed to fetch fresh fee rate, using cached rate.", context: "SweepViewModel")
+ rates = try await CoreService.shared.blocktank.fees(refresh: false)
+ }
+
+ guard let rates else {
+ throw AppError(message: "Fee rates unavailable", debugMessage: nil)
+ }
+
+ feeRates = rates
+ selectedFeeRate = selectedSpeed.getFeeRate(from: rates)
+ }
+
+ /// Reset the view model state
+ func reset() {
+ checkState = .idle
+ sweepState = .idle
+ isPreparingTransaction = false
+ sweepableBalances = nil
+ transactionPreview = nil
+ sweepResult = nil
+ errorMessage = nil
+ selectedFeeRate = nil
+ selectedSpeed = .normal
+ destinationAddress = nil
+ }
+
+ // MARK: - Private Methods
+
+ private func getMnemonic() throws -> String {
+ guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else {
+ throw NSError(
+ domain: "SweepViewModel",
+ code: -1,
+ userInfo: [NSLocalizedDescriptionKey: "Mnemonic not found"]
+ )
+ }
+ return mnemonic
+ }
+
+ private func getPassphrase() throws -> String? {
+ try Keychain.loadString(key: .bip39Passphrase(index: walletIndex))
+ }
+
+ private static func getElectrumUrl() -> String {
+ let configService = ElectrumConfigService()
+ let server = configService.getCurrentServer()
+ return server.fullUrl.isEmpty ? Env.electrumServerUrl : server.fullUrl
+ }
+
+ // MARK: - Static Methods
+
+ /// Check for sweepable funds after migration/restore and show prompt sheet if funds found
+ static func checkAndPromptForSweepableFunds(sheets: SheetViewModel) {
+ Task {
+ let hasSweepableFunds = await checkForSweepableFundsAfterMigration()
+ if hasSweepableFunds {
+ await MainActor.run {
+ sheets.showSheet(.sweepPrompt)
+ }
+ }
+ }
+ }
+
+ /// Check for sweepable funds after migration and return true if funds were found
+ static func checkForSweepableFundsAfterMigration(walletIndex: Int = 0) async -> Bool {
+ do {
+ guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else {
+ Logger.debug("No mnemonic found for sweep check", context: "SweepViewModel")
+ return false
+ }
+
+ let passphrase = try? Keychain.loadString(key: .bip39Passphrase(index: walletIndex))
+ let electrumUrl = Self.getElectrumUrl()
+ let network = Env.bitkitCoreNetwork
+
+ let balances = try await BitkitCore.checkSweepableBalances(
+ mnemonicPhrase: mnemonic,
+ network: network,
+ bip39Passphrase: passphrase,
+ electrumUrl: electrumUrl
+ )
+
+ if balances.totalBalance > 0 {
+ Logger.info("Found \(balances.totalBalance) sats to sweep after migration", context: "SweepViewModel")
+ return true
+ }
+
+ Logger.debug("No sweepable funds found after migration", context: "SweepViewModel")
+ return false
+ } catch {
+ Logger.error("Failed to check sweepable funds after migration: \(error)", context: "SweepViewModel")
+ return false
+ }
+ }
+}
diff --git a/Bitkit/Views/Onboarding/InitializingWalletView.swift b/Bitkit/Views/Onboarding/InitializingWalletView.swift
index b9c35564..894d90d4 100644
--- a/Bitkit/Views/Onboarding/InitializingWalletView.swift
+++ b/Bitkit/Views/Onboarding/InitializingWalletView.swift
@@ -166,6 +166,12 @@ struct InitializingWalletView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+ .onAppear {
+ UIApplication.shared.isIdleTimerDisabled = true
+ }
+ .onDisappear {
+ UIApplication.shared.isIdleTimerDisabled = false
+ }
}
}
diff --git a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift
index 1e722d04..3cce6c7e 100644
--- a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift
+++ b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift
@@ -2,6 +2,7 @@ import SwiftUI
struct WalletRestoreSuccess: View {
@EnvironmentObject var wallet: WalletViewModel
+ @EnvironmentObject var sheets: SheetViewModel
@EnvironmentObject var suggestionsManager: SuggestionsManager
@EnvironmentObject var tagManager: TagManager
@@ -32,6 +33,7 @@ struct WalletRestoreSuccess: View {
tagManager.reloadLastUsedTags()
wallet.isRestoringWallet = false
+ SweepViewModel.checkAndPromptForSweepableFunds(sheets: sheets)
}
.accessibilityIdentifier("GetStartedButton")
}
diff --git a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift
index 2f707f90..dde38459 100644
--- a/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift
+++ b/Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift
@@ -79,6 +79,11 @@ struct AdvancedSettingsView: View {
}
.accessibilityIdentifier("AddressViewer")
+ NavigationLink(value: Route.sweep) {
+ SettingsListLabel(title: t("settings__adv__sweep_funds"))
+ }
+ .accessibilityIdentifier("SweepFunds")
+
// SettingsListLabel(title: t("settings__adv__rescan"), rightIcon: nil)
Button(action: {
diff --git a/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift b/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift
new file mode 100644
index 00000000..aa5fa378
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SweepConfirmView.swift
@@ -0,0 +1,245 @@
+import BitkitCore
+import SwiftUI
+
+struct SweepConfirmView: View {
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var settings: SettingsViewModel
+ @EnvironmentObject private var viewModel: SweepViewModel
+
+ @State private var showPinCheck = false
+ @State private var pinCheckContinuation: CheckedContinuation?
+ @State private var showingBiometricError = false
+ @State private var biometricErrorMessage = ""
+ @State private var isLoadingAddress = true
+
+ private var isLoading: Bool {
+ isLoadingAddress || viewModel.isPreparingTransaction || viewModel.sweepState.isLoading
+ }
+
+ var body: some View {
+ ZStack {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(title: t("sweep__confirm_title"))
+ .padding(.bottom, 16)
+
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 24) {
+ VStack(alignment: .leading, spacing: 8) {
+ if case .ready = viewModel.sweepState, !viewModel.isPreparingTransaction {
+ MoneyStack(
+ sats: Int(viewModel.amountAfterFees),
+ showSymbol: true,
+ testIdPrefix: "SweepAmount"
+ )
+ } else {
+ MoneyStack(
+ sats: Int(viewModel.totalBalance),
+ showSymbol: true,
+ testIdPrefix: "SweepAmount"
+ )
+ .opacity(0.5)
+ }
+ }
+
+ Divider()
+
+ // Destination section
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("sweep__destination"))
+
+ if let address = viewModel.destinationAddress {
+ BodySSBText(address.ellipsis(maxLength: 20))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ } else {
+ BodySSBText("...")
+ .opacity(0.5)
+ }
+ }
+
+ Divider()
+
+ // Fee section
+ Button(action: {
+ navigation.navigate(.sweepFeeRate)
+ }) {
+ HStack {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("wallet__send_fee_and_speed"))
+ HStack(spacing: 0) {
+ Image(viewModel.selectedSpeed.iconName)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .foregroundColor(viewModel.selectedSpeed.iconColor)
+ .frame(width: 16, height: 16)
+ .padding(.trailing, 4)
+
+ if viewModel.estimatedFee > 0, !viewModel.isPreparingTransaction {
+ HStack(spacing: 0) {
+ BodySSBText("\(viewModel.selectedSpeed.displayTitle) (")
+ MoneyText(sats: Int(viewModel.estimatedFee), size: .bodySSB, symbol: true, symbolColor: .textPrimary)
+ BodySSBText(")")
+ }
+
+ Image("pencil")
+ .foregroundColor(.textPrimary)
+ .frame(width: 12, height: 12)
+ .padding(.leading, 6)
+ } else {
+ BodySSBText(viewModel.selectedSpeed.displayTitle)
+ }
+ }
+ }
+
+ Spacer()
+
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("wallet__send_confirming_in"))
+ HStack(spacing: 0) {
+ Image("clock")
+ .foregroundColor(.brandAccent)
+ .frame(width: 16, height: 16)
+ .padding(.trailing, 4)
+
+ BodySSBText(viewModel.selectedSpeed.displayDescription)
+ }
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(isLoading)
+
+ Divider()
+
+ // Error display
+ if let error = viewModel.errorMessage {
+ HStack(spacing: 12) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.redAccent)
+ BodyMText(error)
+ .foregroundColor(.redAccent)
+ }
+ .padding()
+ .background(Color.redAccent.opacity(0.1))
+ .cornerRadius(8)
+ }
+ }
+ }
+
+ Spacer()
+
+ // Bottom button area
+ if case .broadcasting = viewModel.sweepState {
+ VStack(spacing: 32) {
+ ActivityIndicator(size: 32)
+ CaptionMText(t("sweep__broadcasting"))
+ .foregroundColor(.textSecondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ } else if isLoading {
+ VStack(spacing: 32) {
+ ActivityIndicator(size: 32)
+ CaptionMText(t("sweep__preparing"))
+ .foregroundColor(.textSecondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ } else if case .ready = viewModel.sweepState, viewModel.destinationAddress != nil {
+ SwipeButton(title: t("sweep__swipe"), accentColor: .greenAccent) {
+ // Check if authentication is required
+ if settings.requirePinForPayments && settings.pinEnabled {
+ if settings.useBiometrics && BiometricAuth.isAvailable {
+ let result = await BiometricAuth.authenticate()
+ switch result {
+ case .success:
+ break
+ case .cancelled:
+ throw CancellationError()
+ case let .failed(message):
+ biometricErrorMessage = message
+ showingBiometricError = true
+ throw CancellationError()
+ }
+ } else {
+ showPinCheck = true
+ let shouldProceed = try await waitForPinCheck()
+ if !shouldProceed {
+ throw CancellationError()
+ }
+ }
+ }
+
+ // Broadcast the sweep
+ await viewModel.broadcastSweep()
+
+ if case let .success(result) = viewModel.sweepState {
+ navigation.navigate(.sweepSuccess(txid: result.txid))
+ }
+ }
+ }
+ }
+ }
+ .navigationBarHidden(true)
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ .task {
+ await loadDestinationAddress()
+ do {
+ try await viewModel.loadFeeEstimates()
+ } catch {
+ Logger.error("Failed to load fee estimates: \(error)", context: "SweepConfirmView")
+ viewModel.errorMessage = error.localizedDescription
+ }
+ if let address = viewModel.destinationAddress {
+ await viewModel.prepareSweep(destinationAddress: address)
+ }
+ }
+ .onChange(of: viewModel.selectedSpeed) { _ in
+ Task {
+ if let address = viewModel.destinationAddress {
+ await viewModel.prepareSweep(destinationAddress: address)
+ }
+ }
+ }
+ .alert(
+ t("security__bio_error_title"),
+ isPresented: $showingBiometricError
+ ) {
+ Button(t("common__ok")) {}
+ } message: {
+ Text(biometricErrorMessage)
+ }
+ .navigationDestination(isPresented: $showPinCheck) {
+ PinCheckView(
+ title: t("security__pin_send_title"),
+ explanation: t("security__pin_send"),
+ onCancel: {
+ pinCheckContinuation?.resume(returning: false)
+ pinCheckContinuation = nil
+ },
+ onPinVerified: { _ in
+ pinCheckContinuation?.resume(returning: true)
+ pinCheckContinuation = nil
+ }
+ )
+ }
+ }
+
+ private func loadDestinationAddress() async {
+ isLoadingAddress = true
+ do {
+ viewModel.destinationAddress = try await LightningService.shared.newAddress()
+ } catch {
+ Logger.error("Failed to get destination address: \(error)", context: "SweepConfirmView")
+ viewModel.errorMessage = t("sweep__error_destination_address")
+ }
+ isLoadingAddress = false
+ }
+
+ private func waitForPinCheck() async throws -> Bool {
+ try await withCheckedThrowingContinuation { continuation in
+ pinCheckContinuation = continuation
+ }
+ }
+}
diff --git a/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift b/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift
new file mode 100644
index 00000000..28abdcca
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SweepFeeCustomView.swift
@@ -0,0 +1,111 @@
+import SwiftUI
+
+struct SweepFeeCustomView: View {
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject private var viewModel: SweepViewModel
+
+ @State private var feeRate: UInt32 = 1
+ @State private var transactionFee: UInt64 = 0
+
+ private let minFee: UInt32 = 1
+ private let maxFee: UInt32 = 999
+
+ private var isValid: Bool {
+ feeRate >= minFee && feeRate <= maxFee
+ }
+
+ private var estimatedTxVbytes: UInt64 {
+ viewModel.transactionPreview?.estimatedVsize ?? 0
+ }
+
+ private var totalFeeText: String {
+ let fee = UInt64(feeRate) * estimatedTxVbytes
+ return t("sweep__fee_total", variables: ["fee": String(fee)])
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(title: t("wallet__send_fee_custom"))
+ .padding(.horizontal, 16)
+
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("common__sat_vbyte"))
+ .padding(.bottom, 16)
+ .padding(.horizontal, 16)
+
+ HStack {
+ MoneyText(sats: Int(feeRate), symbol: true, color: feeRate == 0 ? .textSecondary : .textPrimary)
+ }
+ .padding(.bottom, 16)
+ .padding(.horizontal, 16)
+
+ if isValid {
+ BodyMText(totalFeeText)
+ .padding(.bottom, 32)
+ .padding(.horizontal, 16)
+ }
+
+ Spacer()
+
+ NumberPad { key in
+ handleNumberPadInput(key)
+ }
+ .padding(.horizontal, 16)
+
+ CustomButton(title: t("common__continue")) {
+ onContinue()
+ }
+ .disabled(!isValid)
+ .padding(.horizontal, 16)
+ .padding(.top, 16)
+ }
+ }
+ .navigationBarHidden(true)
+ .bottomSafeAreaPadding()
+ .task {
+ initializeFromCurrentFee()
+ }
+ }
+
+ private func initializeFromCurrentFee() {
+ if case let .custom(rate) = viewModel.selectedSpeed {
+ feeRate = rate
+ } else {
+ feeRate = viewModel.selectedFeeRate ?? 0
+ }
+ }
+
+ private func handleNumberPadInput(_ key: String) {
+ let current = String(feeRate)
+
+ if key == "delete" {
+ if current.count > 1 {
+ let newString = String(current.dropLast())
+ feeRate = UInt32(newString) ?? 0
+ } else {
+ feeRate = 0
+ }
+ } else {
+ let newString: String = if current == "0" {
+ key
+ } else {
+ current + key
+ }
+
+ // Limit to 3 digits (max 999 sat/vB)
+ if newString.count <= 3, let newRate = UInt32(newString) {
+ feeRate = newRate
+ }
+ }
+ }
+
+ private func onContinue() {
+ guard isValid else { return }
+
+ Task {
+ await viewModel.setFeeRate(speed: .custom(satsPerVByte: feeRate))
+ viewModel.selectedFeeRate = feeRate
+ navigation.navigateBack()
+ }
+ }
+}
diff --git a/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift b/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift
new file mode 100644
index 00000000..9bcb2a7e
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SweepFeeRateView.swift
@@ -0,0 +1,148 @@
+import BitkitCore
+import SwiftUI
+
+struct SweepFeeRateView: View {
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var wallet: WalletViewModel
+ @EnvironmentObject private var viewModel: SweepViewModel
+
+ @State private var isLoading = true
+
+ private var estimatedTxVbytes: UInt64 {
+ viewModel.transactionPreview?.estimatedVsize ?? 0
+ }
+
+ private func getFee(for speed: TransactionSpeed) -> UInt64 {
+ let feeRate: UInt32
+ switch speed {
+ case let .custom(rate):
+ feeRate = rate
+ default:
+ guard let rates = viewModel.feeRates else { return 0 }
+ feeRate = speed.getFeeRate(from: rates)
+ }
+ return UInt64(feeRate) * estimatedTxVbytes
+ }
+
+ private func getAmountAfterFee(for speed: TransactionSpeed) -> UInt64 {
+ let fee = getFee(for: speed)
+ let total = viewModel.totalBalance
+ return total > fee ? total - fee : 0
+ }
+
+ private func isDisabled(for speed: TransactionSpeed) -> Bool {
+ let fee = getFee(for: speed)
+ let totalBalance = viewModel.totalBalance
+ // Disable if fee would leave less than dust limit
+ return fee + UInt64(Env.dustLimit) > totalBalance
+ }
+
+ private func selectFee(_ speed: TransactionSpeed) {
+ Task {
+ await viewModel.setFeeRate(speed: speed)
+ navigation.navigateBack()
+ }
+ }
+
+ private var currentCustomFeeRate: UInt32 {
+ if case let .custom(rate) = viewModel.selectedSpeed {
+ return rate
+ } else {
+ return viewModel.selectedFeeRate ?? 0
+ }
+ }
+
+ private var isCustomSelected: Bool {
+ if case .custom = viewModel.selectedSpeed {
+ return true
+ }
+ return false
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(title: t("wallet__send_fee_speed"))
+ .padding(.horizontal, 16)
+
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("wallet__send_fee_and_speed"))
+ .padding(.bottom, 16)
+ .padding(.horizontal, 16)
+
+ if isLoading {
+ HStack {
+ Spacer()
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle(tint: .brandAccent))
+ Spacer()
+ }
+ .padding(.top, 32)
+ } else {
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 0) {
+ FeeItem(
+ speed: .fast,
+ amount: getFee(for: .fast),
+ isSelected: viewModel.selectedSpeed == .fast,
+ isDisabled: isDisabled(for: .fast)
+ ) {
+ selectFee(.fast)
+ }
+
+ FeeItem(
+ speed: .normal,
+ amount: getFee(for: .normal),
+ isSelected: viewModel.selectedSpeed == .normal,
+ isDisabled: isDisabled(for: .normal)
+ ) {
+ selectFee(.normal)
+ }
+
+ FeeItem(
+ speed: .slow,
+ amount: getFee(for: .slow),
+ isSelected: viewModel.selectedSpeed == .slow,
+ isDisabled: isDisabled(for: .slow)
+ ) {
+ selectFee(.slow)
+ }
+
+ // Custom fee option
+ FeeItem(
+ speed: .custom(satsPerVByte: currentCustomFeeRate),
+ amount: getFee(for: .custom(satsPerVByte: currentCustomFeeRate)),
+ isSelected: isCustomSelected,
+ isDisabled: false
+ ) {
+ navigation.navigate(.sweepFeeCustom)
+ }
+ }
+ }
+ }
+
+ Spacer()
+
+ CustomButton(title: t("common__continue")) {
+ navigation.navigateBack()
+ }
+ .padding(.horizontal, 16)
+ }
+ }
+ .navigationBarHidden(true)
+ .bottomSafeAreaPadding()
+ .task {
+ await loadFeeEstimates()
+ }
+ }
+
+ private func loadFeeEstimates() async {
+ isLoading = true
+ do {
+ try await viewModel.loadFeeEstimates()
+ } catch {
+ Logger.error("Failed to load fee estimates: \(error)", context: "SweepFeeRateView")
+ viewModel.errorMessage = error.localizedDescription
+ }
+ isLoading = false
+ }
+}
diff --git a/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift b/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift
new file mode 100644
index 00000000..e11817ac
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SweepPromptSheet.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct SweepPromptSheetItem: SheetItem {
+ let id: SheetID = .sweepPrompt
+ let size: SheetSize = .large
+}
+
+struct SweepPromptSheet: View {
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var sheets: SheetViewModel
+ let config: SweepPromptSheetItem
+
+ var body: some View {
+ Sheet(id: .sweepPrompt, data: config) {
+ SheetIntro(
+ navTitle: t("sweep__prompt_title"),
+ title: t("sweep__prompt_headline"),
+ description: t("sweep__prompt_description"),
+ image: "coin-stack",
+ continueText: t("sweep__prompt_sweep"),
+ cancelText: t("common__cancel"),
+ testID: "SweepPromptSheet",
+ onCancel: {
+ sheets.hideSheet()
+ },
+ onContinue: {
+ sheets.hideSheet()
+ navigation.navigate(.sweep)
+ }
+ )
+ }
+ }
+}
+
+#Preview {
+ SweepPromptSheet(config: SweepPromptSheetItem())
+ .environmentObject(NavigationViewModel())
+ .environmentObject(SheetViewModel())
+}
diff --git a/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift b/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift
new file mode 100644
index 00000000..3c6e32ce
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SweepSettingsView.swift
@@ -0,0 +1,197 @@
+import BitkitCore
+import Lottie
+import SwiftUI
+
+struct SweepSettingsView: View {
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject private var viewModel: SweepViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(title: navigationTitle)
+ .padding(.bottom, 30)
+
+ switch viewModel.checkState {
+ case .idle, .checking:
+ loadingView
+ case .found:
+ foundFundsView
+ case .noFunds:
+ noFundsView
+ case let .error(message):
+ errorView(message: message)
+ }
+ }
+ .navigationBarHidden(true)
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ .background(Color.customBlack)
+ .task {
+ viewModel.reset()
+ await viewModel.checkBalance()
+ }
+ }
+
+ private var navigationTitle: String {
+ switch viewModel.checkState {
+ case .found:
+ return t("sweep__found_title")
+ case .noFunds:
+ return t("sweep__no_funds_title")
+ default:
+ return t("sweep__title")
+ }
+ }
+
+ // MARK: - Loading View
+
+ @ViewBuilder
+ private var loadingView: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ BodyMText(t("sweep__loading_description"))
+ .foregroundColor(.textSecondary)
+
+ Spacer()
+
+ // Magnifying glass image
+ Image("magnifying-glass-illustration")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 311, height: 311)
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ Spacer()
+
+ // Loading indicator
+ VStack(spacing: 32) {
+ ActivityIndicator(size: 32)
+
+ CaptionMText(t("sweep__looking_for_funds"))
+ .foregroundColor(.textSecondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+
+ // MARK: - Found Funds View
+
+ @ViewBuilder
+ private var foundFundsView: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ BodyMText(t("sweep__found_description"))
+ .foregroundColor(.textSecondary)
+ .padding(.bottom, 24)
+
+ CaptionMText(t("sweep__funds_found"))
+ .foregroundColor(.textSecondary)
+ .padding(.bottom, 16)
+
+ if let balances = viewModel.sweepableBalances {
+ VStack(alignment: .leading, spacing: 0) {
+ if balances.legacyBalance > 0 {
+ fundRow(
+ title: "Legacy (P2PKH)",
+ utxoCount: balances.legacyUtxosCount,
+ balance: balances.legacyBalance
+ )
+ }
+ if balances.p2shBalance > 0 {
+ fundRow(
+ title: "SegWit (P2SH)",
+ utxoCount: balances.p2shUtxosCount,
+ balance: balances.p2shBalance
+ )
+ }
+ if balances.taprootBalance > 0 {
+ fundRow(
+ title: "Taproot (P2TR)",
+ utxoCount: balances.taprootUtxosCount,
+ balance: balances.taprootBalance
+ )
+ }
+
+ // Total row
+ HStack {
+ TitleText(t("common__total"))
+ Spacer()
+ MoneyText(sats: Int(balances.totalBalance), size: .title, symbol: true, symbolColor: .textPrimary)
+ }
+ .padding(.top, 16)
+ }
+ }
+
+ Spacer()
+
+ CustomButton(title: t("sweep__sweep_to_wallet")) {
+ navigation.navigate(.sweepConfirm)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fundRow(title: String, utxoCount: UInt32, balance: UInt64) -> some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("\(title), \(utxoCount) UTXO\(utxoCount == 1 ? "" : "s")")
+ .font(Fonts.semiBold(size: 13))
+ .foregroundColor(.textPrimary)
+ Spacer()
+ MoneyText(sats: Int(balance), size: .captionB, symbol: true, symbolColor: .textPrimary)
+ }
+ .padding(.vertical, 16)
+
+ Divider()
+ .background(Color.gray5)
+ }
+ }
+
+ // MARK: - No Funds View
+
+ @ViewBuilder
+ private var noFundsView: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ BodyMText(t("sweep__no_funds_description"))
+ .foregroundColor(.textSecondary)
+
+ Spacer()
+
+ Image("check")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 311, height: 311)
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ Spacer()
+
+ CustomButton(title: t("common__ok")) {
+ navigation.navigateBack()
+ }
+ }
+ }
+
+ // MARK: - Error View
+
+ @ViewBuilder
+ private func errorView(message: String) -> some View {
+ VStack(spacing: 24) {
+ Spacer()
+
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 64))
+ .foregroundColor(.redAccent)
+
+ VStack(spacing: 8) {
+ BodyMSBText(t("sweep__error_title"))
+ BodyMText(message)
+ .foregroundColor(.textSecondary)
+ .multilineTextAlignment(.center)
+ }
+
+ Spacer()
+
+ CustomButton(title: t("common__retry")) {
+ Task { await viewModel.checkBalance() }
+ }
+ }
+ }
+}
diff --git a/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift b/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift
new file mode 100644
index 00000000..3145ea36
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SweepSuccessView.swift
@@ -0,0 +1,64 @@
+import BitkitCore
+import Lottie
+import SwiftUI
+
+struct SweepSuccessView: View {
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var viewModel: SweepViewModel
+
+ let txid: String
+
+ private var confettiAnimation: LottieAnimation? {
+ guard let filepathURL = Bundle.main.url(forResource: "confetti-orange", withExtension: "json") else {
+ return nil
+ }
+ return LottieAnimation.filepath(filepathURL.path)
+ }
+
+ private var amountSwept: UInt64 {
+ viewModel.sweepResult?.amountSwept ?? 0
+ }
+
+ var body: some View {
+ ZStack {
+ if let animation = confettiAnimation {
+ LottieView(animation: animation)
+ .playing(loopMode: .loop)
+ .scaleEffect(1.9)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(title: t("sweep__complete_title"))
+ .padding(.bottom, 16)
+
+ BodyMText(t("sweep__complete_description"))
+ .foregroundColor(.textSecondary)
+ .padding(.bottom, 24)
+
+ VStack(alignment: .leading, spacing: 16) {
+ MoneyText(sats: Int(amountSwept), unitType: .secondary, size: .caption, color: .textSecondary)
+ MoneyText(sats: Int(amountSwept), size: .display, symbol: true, symbolColor: .textSecondary)
+ }
+
+ Spacer()
+
+ Image("check")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 256, height: 256)
+ .frame(maxWidth: .infinity)
+
+ Spacer()
+
+ CustomButton(title: t("sweep__wallet_overview")) {
+ navigation.reset()
+ }
+ }
+ .padding(.horizontal, 16)
+ }
+ .navigationBarHidden(true)
+ .bottomSafeAreaPadding()
+ .background(Color.customBlack)
+ }
+}
diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
index cd39e4d1..83cc9d2d 100644
--- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
+++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
@@ -1,5 +1,4 @@
import BitkitCore
-import LocalAuthentication
import SwiftUI
struct LnurlPayConfirm: View {
@@ -18,23 +17,6 @@ struct LnurlPayConfirm: View {
@State private var comment = ""
@FocusState private var isCommentFocused: Bool
- private var biometryTypeName: String {
- switch Env.biometryType {
- case .touchID:
- return t("security__bio_touch_id")
- case .faceID:
- return t("security__bio_face_id")
- default:
- return t("security__bio_face_id") // Default to Face ID
- }
- }
-
- private var isBiometricAvailable: Bool {
- let context = LAContext()
- var error: NSError?
- return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
- }
-
var uri: String {
app.lnurlPayData!.uri
}
@@ -141,27 +123,27 @@ struct LnurlPayConfirm: View {
// Check if authentication is required for payments
if settings.requirePinForPayments && settings.pinEnabled {
- // Use biometrics if available and enabled, otherwise use PIN
- if settings.useBiometrics && isBiometricAvailable {
- let shouldProceed = try await requestBiometricAuthentication()
- if !shouldProceed {
- // User cancelled biometric authentication, throw error to reset SwipeButton
+ if settings.useBiometrics && BiometricAuth.isAvailable {
+ let result = await BiometricAuth.authenticate()
+ switch result {
+ case .success:
+ break
+ case .cancelled:
+ throw CancellationError()
+ case let .failed(message):
+ biometricErrorMessage = message
+ showingBiometricError = true
throw CancellationError()
}
- // Biometric authentication successful, continue with payment
} else {
- // Fall back to PIN
showPinCheck = true
let shouldProceed = try await waitForPinCheck()
if !shouldProceed {
- // User cancelled PIN entry, throw error to reset SwipeButton
throw CancellationError()
}
- // PIN verified, continue with payment
}
}
- // Proceed with payment
try await performPayment()
}
}
@@ -218,66 +200,6 @@ struct LnurlPayConfirm: View {
}
}
- private func requestBiometricAuthentication() async throws -> Bool {
- return try await withCheckedThrowingContinuation { continuation in
- let context = LAContext()
- var error: NSError?
-
- // Check if biometric authentication is available
- guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
- handleBiometricError(error)
- continuation.resume(returning: false)
- return
- }
-
- // Request biometric authentication
- let reason = t(
- "security__bio_confirm",
- variables: ["biometricsName": biometryTypeName]
- )
-
- context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in
- DispatchQueue.main.async {
- if success {
- Logger.debug("Biometric authentication successful for payment", context: "SendConfirmationView")
- continuation.resume(returning: true)
- } else {
- if let error = authenticationError {
- handleBiometricError(error)
- }
- continuation.resume(returning: false)
- }
- }
- }
- }
- }
-
- private func handleBiometricError(_ error: Error?) {
- guard let error else { return }
-
- let nsError = error as NSError
-
- switch nsError.code {
- case LAError.biometryNotAvailable.rawValue:
- biometricErrorMessage = t("security__bio_not_available")
- showingBiometricError = true
- case LAError.biometryNotEnrolled.rawValue:
- biometricErrorMessage = t("security__bio_not_available")
- showingBiometricError = true
- case LAError.userCancel.rawValue, LAError.userFallback.rawValue:
- // User cancelled - don't show error, just keep current state
- return
- default:
- biometricErrorMessage = t(
- "security__bio_error_message",
- variables: ["type": biometryTypeName]
- )
- showingBiometricError = true
- }
-
- Logger.error("Biometric authentication error: \(error)", context: "SendConfirmationView")
- }
-
private func performPayment() async throws {
guard let lnurlPayData = app.lnurlPayData else {
throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"])
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 10fcabe0..59176bf1 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -1,5 +1,4 @@
import BitkitCore
-import LocalAuthentication
import SwiftUI
struct SendConfirmationView: View {
@@ -56,23 +55,6 @@ struct SendConfirmationView: View {
@State private var pendingWarnings: [WarningType] = []
@State private var warningContinuation: CheckedContinuation?
- private var biometryTypeName: String {
- switch Env.biometryType {
- case .touchID:
- return t("security__bio_touch_id")
- case .faceID:
- return t("security__bio_face_id")
- default:
- return t("security__bio_face_id") // Default to Face ID
- }
- }
-
- private var isBiometricAvailable: Bool {
- let context = LAContext()
- var error: NSError?
- return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
- }
-
private var canEditAmount: Bool {
guard app.selectedWalletToPayFrom == .lightning else {
return true
@@ -137,28 +119,27 @@ struct SendConfirmationView: View {
// Check if authentication is required for payments
if settings.requirePinForPayments && settings.pinEnabled {
- // Use biometrics if available and enabled, otherwise use PIN
- if settings.useBiometrics && isBiometricAvailable {
- let shouldProceed = try await requestBiometricAuthentication()
- if !shouldProceed {
- // User cancelled biometric authentication, throw error to reset SwipeButton
-
+ if settings.useBiometrics && BiometricAuth.isAvailable {
+ let result = await BiometricAuth.authenticate()
+ switch result {
+ case .success:
+ break
+ case .cancelled:
+ throw CancellationError()
+ case let .failed(message):
+ biometricErrorMessage = message
+ showingBiometricError = true
throw CancellationError()
}
- // Biometric authentication successful, continue with payment
} else {
- // Fall back to PIN
showPinCheck = true
let shouldProceed = try await waitForPinCheck()
if !shouldProceed {
- // User cancelled PIN entry, throw error to reset SwipeButton
throw CancellationError()
}
- // PIN verified, continue with payment
}
}
- // Proceed with payment
try await performPayment()
}
}
@@ -226,66 +207,6 @@ struct SendConfirmationView: View {
}
}
- private func requestBiometricAuthentication() async throws -> Bool {
- return try await withCheckedThrowingContinuation { continuation in
- let context = LAContext()
- var error: NSError?
-
- // Check if biometric authentication is available
- guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
- handleBiometricError(error)
- continuation.resume(returning: false)
- return
- }
-
- // Request biometric authentication
- let reason = t(
- "security__bio_confirm",
- variables: ["biometricsName": biometryTypeName]
- )
-
- context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in
- DispatchQueue.main.async {
- if success {
- Logger.debug("Biometric authentication successful for payment", context: "SendConfirmationView")
- continuation.resume(returning: true)
- } else {
- if let error = authenticationError {
- handleBiometricError(error)
- }
- continuation.resume(returning: false)
- }
- }
- }
- }
- }
-
- private func handleBiometricError(_ error: Error?) {
- guard let error else { return }
-
- let nsError = error as NSError
-
- switch nsError.code {
- case LAError.biometryNotAvailable.rawValue:
- biometricErrorMessage = t("security__bio_not_available")
- showingBiometricError = true
- case LAError.biometryNotEnrolled.rawValue:
- biometricErrorMessage = t("security__bio_not_available")
- showingBiometricError = true
- case LAError.userCancel.rawValue, LAError.userFallback.rawValue:
- // User cancelled - don't show error, just keep current state
- return
- default:
- biometricErrorMessage = t(
- "security__bio_error_message",
- variables: ["type": biometryTypeName]
- )
- showingBiometricError = true
- }
-
- Logger.error("Biometric authentication error: \(error)", context: "SendConfirmationView")
- }
-
private func performPayment() async throws {
var createdMetadataPaymentId: String? = nil