diff --git a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj index 3c22d322..8698edde 100644 --- a/BDKSwiftExampleWallet.xcodeproj/project.pbxproj +++ b/BDKSwiftExampleWallet.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ AE2B8C1D2A9678C900815B2F /* FeeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2B8C1C2A9678C900815B2F /* FeeService.swift */; }; AE2B8C1F2A96797300815B2F /* RecommendedFees.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2B8C1E2A96797300815B2F /* RecommendedFees.swift */; }; AE2F255D2BED0BFB002A9AC6 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F255C2BED0BFB002A9AC6 /* AppError.swift */; }; + 4F4D7EDC0B4EB26402929104 /* WifParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC190D44F8616C5DC06AD853 /* WifParser.swift */; }; AE34DDAC2B6B31ED00F04AD4 /* WalletRecoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE34DDAB2B6B31ED00F04AD4 /* WalletRecoveryView.swift */; }; AE34DDAE2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE34DDAD2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift */; }; AE3646262BEDB01200B04E25 /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */; }; @@ -145,6 +146,7 @@ AE2B8C1C2A9678C900815B2F /* FeeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeeService.swift; sourceTree = ""; }; AE2B8C1E2A96797300815B2F /* RecommendedFees.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendedFees.swift; sourceTree = ""; }; AE2F255C2BED0BFB002A9AC6 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + FC190D44F8616C5DC06AD853 /* WifParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifParser.swift; sourceTree = ""; }; AE34DDAB2B6B31ED00F04AD4 /* WalletRecoveryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletRecoveryView.swift; sourceTree = ""; }; AE34DDAD2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletRecoveryViewModel.swift; sourceTree = ""; }; AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; @@ -308,6 +310,7 @@ children = ( AE79538D2A2D59F000CCB277 /* Constants.swift */, AE2F255C2BED0BFB002A9AC6 /* AppError.swift */, + FC190D44F8616C5DC06AD853 /* WifParser.swift */, ); path = Utilities; sourceTree = ""; @@ -722,6 +725,7 @@ AE18E9382A9528200019D2A4 /* Bundle+Extensions.swift in Sources */, AE79538E2A2D59F000CCB277 /* Constants.swift in Sources */, AE2F255D2BED0BFB002A9AC6 /* AppError.swift in Sources */, + 4F4D7EDC0B4EB26402929104 /* WifParser.swift in Sources */, AEE6C74F2ABCBA4600442ADD /* WalletSyncState.swift in Sources */, AE1C34242A424456008F807A /* ReceiveView.swift in Sources */, AE91CEED2C0FDB70000AAD20 /* SentAndReceivedValues+Extensions.swift in Sources */, diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift index 7343f516..331093f9 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift @@ -555,6 +555,85 @@ final class BDKService { try await signAndBroadcast(psbt: psbt) } + func sweepWif(wif: String, feeRate: UInt64) async throws -> [Txid] { + // Keep sweep minimal and predictable across signet/testnet/testnet4 in this example: + // use the Esplora path only. + guard self.clientType == .esplora else { + throw WalletError.sweepEsploraOnly + } + + let destinationAddress = try getAddress() + let destinationScript = try Address(address: destinationAddress, network: self.network) + .scriptPubkey() + + let candidates = [ + "pkh(\(wif))", + "wpkh(\(wif))", + "sh(wpkh(\(wif)))", + "tr(\(wif))", + ] + + var sweptTxids: [Txid] = [] + var lastWIFOperationError: Error? + for descriptorString in candidates { + guard + let descriptor = try? Descriptor( + descriptor: descriptorString, + network: self.network + ) + else { + continue + } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("bdk-sweep-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let tempPath = tempDir.appendingPathComponent("wallet.sqlite").path + let sweepPersister = try Persister.newSqlite(path: tempPath) + let sweepWallet = try Wallet.createSingle( + descriptor: descriptor, + network: self.network, + persister: sweepPersister + ) + + let _ = sweepWallet.revealNextAddress(keychain: .external) + let syncRequest = try sweepWallet.startSyncWithRevealedSpks().build() + let update = try await self.blockchainClient.sync(syncRequest, UInt64(5)) + try sweepWallet.applyUpdate(update: update) + + if sweepWallet.balance().total.toSat() == 0 { + continue + } + + do { + let psbt = try TxBuilder() + .drainTo(script: destinationScript) + .drainWallet() + .feeRate(feeRate: FeeRate.fromSatPerVb(satVb: feeRate)) + .finish(wallet: sweepWallet) + + guard try sweepWallet.sign(psbt: psbt) else { + continue + } + + let tx = try psbt.extractTx() + try await self.blockchainClient.broadcast(tx) + sweptTxids.append(tx.computeTxid()) + } catch { + lastWIFOperationError = error + continue + } + } + + guard !sweptTxids.isEmpty else { + throw lastWIFOperationError ?? WalletError.noSweepableFunds + } + + return sweptTxids + } + func buildTransaction(address: String, amount: UInt64, feeRate: UInt64) throws -> Psbt { @@ -768,6 +847,7 @@ struct BDKClient { let fullScanWithInspector: (FullScanScriptInspector) async throws -> Void let getAddress: () throws -> String let send: (String, UInt64, UInt64) throws -> Void + let sweepWif: (String, UInt64) async throws -> [Txid] let calculateFee: (Transaction) throws -> Amount let calculateFeeRate: (Transaction) throws -> UInt64 let sentAndReceived: (Transaction) throws -> SentAndReceivedValues @@ -812,6 +892,9 @@ extension BDKClient { try await BDKService.shared.send(address: address, amount: amount, feeRate: feeRate) } }, + sweepWif: { (wif, feeRate) in + try await BDKService.shared.sweepWif(wif: wif, feeRate: feeRate) + }, calculateFee: { tx in try BDKService.shared.calculateFee(tx: tx) }, calculateFeeRate: { tx in try BDKService.shared.calculateFeeRate(tx: tx) }, sentAndReceived: { tx in try BDKService.shared.sentAndReceived(tx: tx) }, @@ -874,6 +957,7 @@ extension BDKClient { fullScanWithInspector: { _ in }, getAddress: { "tb1pd8jmenqpe7rz2mavfdx7uc8pj7vskxv4rl6avxlqsw2u8u7d4gfs97durt" }, send: { _, _, _ in }, + sweepWif: { _, _ in [] }, calculateFee: { _ in Amount.mock }, calculateFeeRate: { _ in UInt64(6.15) }, sentAndReceived: { _ in diff --git a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift index 60f12fa4..f51d1716 100644 --- a/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift +++ b/BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift @@ -14,6 +14,8 @@ enum WalletError: Error { case walletNotFound case fullScanUnsupported case backendNotImplemented + case sweepEsploraOnly + case noSweepableFunds } extension WalletError: LocalizedError { @@ -31,6 +33,10 @@ extension WalletError: LocalizedError { return "Full scan is not supported by the selected blockchain client" case .backendNotImplemented: return "The selected blockchain backend is not yet implemented" + case .sweepEsploraOnly: + return "Sweep is available only with Esplora in this example app" + case .noSweepableFunds: + return "No sweepable funds found for this WIF" } } } diff --git a/BDKSwiftExampleWallet/Utilities/WifParser.swift b/BDKSwiftExampleWallet/Utilities/WifParser.swift new file mode 100644 index 00000000..d93a7e50 --- /dev/null +++ b/BDKSwiftExampleWallet/Utilities/WifParser.swift @@ -0,0 +1,57 @@ +// +// WifParser.swift +// BDKSwiftExampleWallet +// +// Created by otaliptus on 3/3/26. +// + +import Foundation + +// Note: this parser is just a pretty simple heuristic for the simple wallet +enum WifParser { + static func extract(from value: String) -> String? { + var candidates = [value] + + if let components = URLComponents(string: value), + let queryItems = components.queryItems + { + for item in queryItems { + let key = item.name.lowercased() + if key == "wif" || key == "privkey" || key == "private_key" || key == "privatekey", + let itemValue = item.value + { + candidates.append(itemValue) + } + } + } + + for candidate in candidates { + var token = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + + if token.lowercased().hasPrefix("wif:") { + token = String(token.dropFirst(4)) + } + + if isLikelyWif(token) { + return token + } + } + + return nil + } + + static func isLikelyWif(_ value: String) -> Bool { + guard value.count == 51 || value.count == 52 else { + return false + } + + guard let first = value.first, "5KL9c".contains(first) else { + return false + } + + let base58Charset = CharacterSet( + charactersIn: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + ) + return value.unicodeScalars.allSatisfy { base58Charset.contains($0) } + } +} diff --git a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift index 9ac30db2..0ec7cefd 100644 --- a/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift +++ b/BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift @@ -145,6 +145,12 @@ class OnboardingViewModel: ObservableObject { Task { do { + if WifParser.extract(from: self.words) != nil { + throw AppError.generic( + message: + "WIF is for sweep, not wallet creation. Open an existing wallet and use Send > Scan/Paste to sweep it." + ) + } if self.isDescriptor { try self.bdkClient.createWalletFromDescriptor(self.words) } else if self.isXPub { @@ -162,6 +168,11 @@ class OnboardingViewModel: ObservableObject { self.isCreatingWallet = false self.createWithPersistError = error } + } catch let error as AppError { + DispatchQueue.main.async { + self.isCreatingWallet = false + self.onboardingViewError = error + } } catch { DispatchQueue.main.async { self.isCreatingWallet = false diff --git a/BDKSwiftExampleWallet/View/OnboardingView.swift b/BDKSwiftExampleWallet/View/OnboardingView.swift index d4d49909..4e07f2df 100644 --- a/BDKSwiftExampleWallet/View/OnboardingView.swift +++ b/BDKSwiftExampleWallet/View/OnboardingView.swift @@ -225,6 +225,7 @@ struct OnboardingView: View { message: Text(viewModel.onboardingViewError?.description ?? "Unknown"), dismissButton: .default(Text("OK")) { viewModel.onboardingViewError = nil + showingOnboardingViewErrorAlert = false } ) } @@ -251,6 +252,9 @@ struct OnboardingView: View { animateContent = true } } + .onReceive(viewModel.$onboardingViewError) { error in + showingOnboardingViewErrorAlert = (error != nil) + } } } diff --git a/BDKSwiftExampleWallet/View/Send/AddressView.swift b/BDKSwiftExampleWallet/View/Send/AddressView.swift index a56a6758..a5f51cb8 100644 --- a/BDKSwiftExampleWallet/View/Send/AddressView.swift +++ b/BDKSwiftExampleWallet/View/Send/AddressView.swift @@ -14,7 +14,11 @@ struct AddressView: View { @Binding var navigationPath: NavigationPath @State var address: String = "" @State private var isShowingAlert = false + @State private var alertTitle = "Error" @State private var alertMessage = "" + @State private var isSweeping = false + private let bdkClient: BDKClient = .live + private let sweepFeeRate: UInt64 = 2 let pasteboard = UIPasteboard.general var body: some View { @@ -29,7 +33,7 @@ struct AddressView: View { ) .alert(isPresented: $isShowingAlert) { Alert( - title: Text("Error"), + title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")) ) @@ -45,7 +49,14 @@ extension AddressView { func handleScan(result: Result) { switch result { case .success(let result): - let scannedAddress = result.string.lowercased().replacingOccurrences( + let scannedValue = result.string.trimmingCharacters(in: .whitespacesAndNewlines) + + if let wif = WifParser.extract(from: scannedValue) { + sweep(wif: wif) + return + } + + let scannedAddress = scannedValue.lowercased().replacingOccurrences( of: "bitcoin:", with: "" ) @@ -54,10 +65,12 @@ extension AddressView { address = bitcoinAddress navigationPath.append(NavigationDestination.amount(address: bitcoinAddress)) } else { + alertTitle = "Error" alertMessage = "The scanned QR code did not contain a valid Bitcoin address." isShowingAlert = true } case .failure(let error): + alertTitle = "Error" alertMessage = "Scanning failed: \(error.localizedDescription)" isShowingAlert = true } @@ -66,24 +79,68 @@ extension AddressView { private func pasteAddress() { if let pasteboardContent = UIPasteboard.general.string { if pasteboardContent.isEmpty { + alertTitle = "Error" alertMessage = "The pasteboard is empty." isShowingAlert = true return } let trimmedContent = pasteboardContent.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedContent.isEmpty { + alertTitle = "Error" alertMessage = "The pasteboard contains only whitespace." isShowingAlert = true return } + + if let wif = WifParser.extract(from: trimmedContent) { + sweep(wif: wif) + return + } + let lowercaseAddress = trimmedContent.lowercased() address = lowercaseAddress navigationPath.append(NavigationDestination.amount(address: address)) } else { + alertTitle = "Error" alertMessage = "Unable to access the pasteboard. Please try copying the address again." isShowingAlert = true } } + + private func sweep(wif: String) { + guard !isSweeping else { return } + isSweeping = true + + Task { + defer { + Task { @MainActor in + isSweeping = false + } + } + + do { + let txids = try await bdkClient.sweepWif(wif, sweepFeeRate) + let txidText = txids.map { "\($0)" }.joined(separator: ", ") + + await MainActor.run { + alertTitle = "Success" + alertMessage = "Sweep broadcasted: \(txidText)" + isShowingAlert = true + NotificationCenter.default.post( + name: Notification.Name("TransactionSent"), + object: nil + ) + } + } catch { + await MainActor.run { + alertTitle = "Error" + alertMessage = "Sweep failed: \(error.localizedDescription)" + isShowingAlert = true + } + } + } + } + } struct CustomScannerView: View { diff --git a/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletTests.swift b/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletTests.swift index 884210b9..fc973237 100644 --- a/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletTests.swift +++ b/BDKSwiftExampleWalletTests/BDKSwiftExampleWalletTests.swift @@ -9,4 +9,20 @@ import XCTest @testable import BDKSwiftExampleWallet -final class BDKSwiftExampleWalletTests: XCTestCase {} +final class BDKSwiftExampleWalletTests: XCTestCase { + + func testExtractWifDetectsPrefixedWifAndRejectsRandomString() { + let likelyWif = "c" + String(repeating: "1", count: 51) + + XCTAssertEqual(WifParser.extract(from: "wif:\(likelyWif)"), likelyWif) + XCTAssertEqual( + WifParser.extract(from: "bitcoin:?wif=\(likelyWif)"), + likelyWif + ) + XCTAssertNil( + WifParser.extract( + from: "12cUi8cuUJRiFmGEu4jCAsonSS1dkVyaD7Aoo6URRiXpmaokikuyM778786" + ) + ) + } +}