Skip to content

Commit a380278

Browse files
authored
feat: add WIF QR sweep flow
1 parent a98922d commit a380278

File tree

8 files changed

+251
-3
lines changed

8 files changed

+251
-3
lines changed

BDKSwiftExampleWallet.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
AE2B8C1D2A9678C900815B2F /* FeeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2B8C1C2A9678C900815B2F /* FeeService.swift */; };
3939
AE2B8C1F2A96797300815B2F /* RecommendedFees.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2B8C1E2A96797300815B2F /* RecommendedFees.swift */; };
4040
AE2F255D2BED0BFB002A9AC6 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F255C2BED0BFB002A9AC6 /* AppError.swift */; };
41+
4F4D7EDC0B4EB26402929104 /* WifParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC190D44F8616C5DC06AD853 /* WifParser.swift */; };
4142
AE34DDAC2B6B31ED00F04AD4 /* WalletRecoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE34DDAB2B6B31ED00F04AD4 /* WalletRecoveryView.swift */; };
4243
AE34DDAE2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE34DDAD2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift */; };
4344
AE3646262BEDB01200B04E25 /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */; };
@@ -145,6 +146,7 @@
145146
AE2B8C1C2A9678C900815B2F /* FeeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeeService.swift; sourceTree = "<group>"; };
146147
AE2B8C1E2A96797300815B2F /* RecommendedFees.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendedFees.swift; sourceTree = "<group>"; };
147148
AE2F255C2BED0BFB002A9AC6 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
149+
FC190D44F8616C5DC06AD853 /* WifParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifParser.swift; sourceTree = "<group>"; };
148150
AE34DDAB2B6B31ED00F04AD4 /* WalletRecoveryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletRecoveryView.swift; sourceTree = "<group>"; };
149151
AE34DDAD2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletRecoveryViewModel.swift; sourceTree = "<group>"; };
150152
AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
@@ -308,6 +310,7 @@
308310
children = (
309311
AE79538D2A2D59F000CCB277 /* Constants.swift */,
310312
AE2F255C2BED0BFB002A9AC6 /* AppError.swift */,
313+
FC190D44F8616C5DC06AD853 /* WifParser.swift */,
311314
);
312315
path = Utilities;
313316
sourceTree = "<group>";
@@ -722,6 +725,7 @@
722725
AE18E9382A9528200019D2A4 /* Bundle+Extensions.swift in Sources */,
723726
AE79538E2A2D59F000CCB277 /* Constants.swift in Sources */,
724727
AE2F255D2BED0BFB002A9AC6 /* AppError.swift in Sources */,
728+
4F4D7EDC0B4EB26402929104 /* WifParser.swift in Sources */,
725729
AEE6C74F2ABCBA4600442ADD /* WalletSyncState.swift in Sources */,
726730
AE1C34242A424456008F807A /* ReceiveView.swift in Sources */,
727731
AE91CEED2C0FDB70000AAD20 /* SentAndReceivedValues+Extensions.swift in Sources */,

BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,94 @@ final class BDKService {
555555
try await signAndBroadcast(psbt: psbt)
556556
}
557557

558+
func sweepWif(wif: String, feeRate: UInt64) async throws -> [Txid] {
559+
// Keep sweep minimal and predictable across signet/testnet/testnet4 in this example:
560+
// use the Esplora path only.
561+
guard self.clientType == .esplora else {
562+
throw WalletError.sweepEsploraOnly
563+
}
564+
565+
let candidates = [
566+
"pkh(\(wif))",
567+
"wpkh(\(wif))",
568+
"sh(wpkh(\(wif)))",
569+
"tr(\(wif))",
570+
]
571+
572+
var sweptTxids: [Txid] = []
573+
var lastWIFOperationError: Error?
574+
575+
var destinationScript: Script?
576+
577+
for descriptorString in candidates {
578+
guard
579+
let descriptor = try? Descriptor(
580+
descriptor: descriptorString,
581+
network: self.network
582+
)
583+
else {
584+
continue
585+
}
586+
587+
let tempDir = FileManager.default.temporaryDirectory
588+
.appendingPathComponent("bdk-sweep-\(UUID().uuidString)", isDirectory: true)
589+
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
590+
defer { try? FileManager.default.removeItem(at: tempDir) }
591+
592+
let tempPath = tempDir.appendingPathComponent("wallet.sqlite").path
593+
let sweepPersister = try Persister.newSqlite(path: tempPath)
594+
let sweepWallet = try Wallet.createSingle(
595+
descriptor: descriptor,
596+
network: self.network,
597+
persister: sweepPersister
598+
)
599+
600+
let _ = sweepWallet.revealNextAddress(keychain: .external)
601+
let syncRequest = try sweepWallet.startSyncWithRevealedSpks().build()
602+
let update = try await self.blockchainClient.sync(syncRequest, UInt64(5))
603+
try sweepWallet.applyUpdate(update: update)
604+
605+
if sweepWallet.balance().total.toSat() == 0 {
606+
continue
607+
}
608+
609+
if destinationScript == nil {
610+
let destinationAddress = try getAddress()
611+
destinationScript = try Address(address: destinationAddress, network: self.network)
612+
.scriptPubkey()
613+
}
614+
615+
guard let destinationScript else {
616+
throw WalletError.noSweepableFunds
617+
}
618+
619+
do {
620+
let psbt = try TxBuilder()
621+
.drainTo(script: destinationScript)
622+
.drainWallet()
623+
.feeRate(feeRate: FeeRate.fromSatPerVb(satVb: feeRate))
624+
.finish(wallet: sweepWallet)
625+
626+
guard try sweepWallet.sign(psbt: psbt) else {
627+
continue
628+
}
629+
630+
let tx = try psbt.extractTx()
631+
try await self.blockchainClient.broadcast(tx)
632+
sweptTxids.append(tx.computeTxid())
633+
} catch {
634+
lastWIFOperationError = error
635+
continue
636+
}
637+
}
638+
639+
guard !sweptTxids.isEmpty else {
640+
throw lastWIFOperationError ?? WalletError.noSweepableFunds
641+
}
642+
643+
return sweptTxids
644+
}
645+
558646
func buildTransaction(address: String, amount: UInt64, feeRate: UInt64) throws
559647
-> Psbt
560648
{
@@ -768,6 +856,7 @@ struct BDKClient {
768856
let fullScanWithInspector: (FullScanScriptInspector) async throws -> Void
769857
let getAddress: () throws -> String
770858
let send: (String, UInt64, UInt64) throws -> Void
859+
let sweepWif: (String, UInt64) async throws -> [Txid]
771860
let calculateFee: (Transaction) throws -> Amount
772861
let calculateFeeRate: (Transaction) throws -> UInt64
773862
let sentAndReceived: (Transaction) throws -> SentAndReceivedValues
@@ -812,6 +901,9 @@ extension BDKClient {
812901
try await BDKService.shared.send(address: address, amount: amount, feeRate: feeRate)
813902
}
814903
},
904+
sweepWif: { (wif, feeRate) in
905+
try await BDKService.shared.sweepWif(wif: wif, feeRate: feeRate)
906+
},
815907
calculateFee: { tx in try BDKService.shared.calculateFee(tx: tx) },
816908
calculateFeeRate: { tx in try BDKService.shared.calculateFeeRate(tx: tx) },
817909
sentAndReceived: { tx in try BDKService.shared.sentAndReceived(tx: tx) },
@@ -874,6 +966,7 @@ extension BDKClient {
874966
fullScanWithInspector: { _ in },
875967
getAddress: { "tb1pd8jmenqpe7rz2mavfdx7uc8pj7vskxv4rl6avxlqsw2u8u7d4gfs97durt" },
876968
send: { _, _, _ in },
969+
sweepWif: { _, _ in [] },
877970
calculateFee: { _ in Amount.mock },
878971
calculateFeeRate: { _ in UInt64(6.15) },
879972
sentAndReceived: { _ in

BDKSwiftExampleWallet/Service/BDK Service/BDKSwiftExampleWalletError.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ enum WalletError: Error {
1414
case walletNotFound
1515
case fullScanUnsupported
1616
case backendNotImplemented
17+
case sweepEsploraOnly
18+
case noSweepableFunds
1719
}
1820

1921
extension WalletError: LocalizedError {
@@ -31,6 +33,10 @@ extension WalletError: LocalizedError {
3133
return "Full scan is not supported by the selected blockchain client"
3234
case .backendNotImplemented:
3335
return "The selected blockchain backend is not yet implemented"
36+
case .sweepEsploraOnly:
37+
return "Sweep is available only with Esplora in this example app"
38+
case .noSweepableFunds:
39+
return "No sweepable funds found for this WIF"
3440
}
3541
}
3642
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// WifParser.swift
3+
// BDKSwiftExampleWallet
4+
//
5+
// Created by otaliptus on 3/3/26.
6+
//
7+
8+
import Foundation
9+
10+
// Note: this parser is just a pretty simple heuristic for the simple wallet
11+
enum WifParser {
12+
static func extract(from value: String) -> String? {
13+
var candidates = [value]
14+
15+
if let components = URLComponents(string: value),
16+
let queryItems = components.queryItems
17+
{
18+
for item in queryItems {
19+
let key = item.name.lowercased()
20+
if key == "wif" || key == "privkey" || key == "private_key" || key == "privatekey",
21+
let itemValue = item.value
22+
{
23+
candidates.append(itemValue)
24+
}
25+
}
26+
}
27+
28+
for candidate in candidates {
29+
var token = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
30+
31+
if token.lowercased().hasPrefix("wif:") {
32+
token = String(token.dropFirst(4))
33+
}
34+
35+
if isLikelyWif(token) {
36+
return token
37+
}
38+
}
39+
40+
return nil
41+
}
42+
43+
static func isLikelyWif(_ value: String) -> Bool {
44+
guard value.count == 51 || value.count == 52 else {
45+
return false
46+
}
47+
48+
guard let first = value.first, "5KL9c".contains(first) else {
49+
return false
50+
}
51+
52+
let base58Charset = CharacterSet(
53+
charactersIn: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
54+
)
55+
return value.unicodeScalars.allSatisfy { base58Charset.contains($0) }
56+
}
57+
}

BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ class OnboardingViewModel: ObservableObject {
145145

146146
Task {
147147
do {
148+
if WifParser.extract(from: self.words) != nil {
149+
throw AppError.generic(
150+
message:
151+
"WIF is for sweep, not wallet creation. Open an existing wallet and use Send > Scan/Paste to sweep it."
152+
)
153+
}
148154
if self.isDescriptor {
149155
try self.bdkClient.createWalletFromDescriptor(self.words)
150156
} else if self.isXPub {
@@ -162,6 +168,11 @@ class OnboardingViewModel: ObservableObject {
162168
self.isCreatingWallet = false
163169
self.createWithPersistError = error
164170
}
171+
} catch let error as AppError {
172+
DispatchQueue.main.async {
173+
self.isCreatingWallet = false
174+
self.onboardingViewError = error
175+
}
165176
} catch {
166177
DispatchQueue.main.async {
167178
self.isCreatingWallet = false

BDKSwiftExampleWallet/View/OnboardingView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ struct OnboardingView: View {
225225
message: Text(viewModel.onboardingViewError?.description ?? "Unknown"),
226226
dismissButton: .default(Text("OK")) {
227227
viewModel.onboardingViewError = nil
228+
showingOnboardingViewErrorAlert = false
228229
}
229230
)
230231
}
@@ -251,6 +252,9 @@ struct OnboardingView: View {
251252
animateContent = true
252253
}
253254
}
255+
.onReceive(viewModel.$onboardingViewError) { error in
256+
showingOnboardingViewErrorAlert = (error != nil)
257+
}
254258
}
255259
}
256260

BDKSwiftExampleWallet/View/Send/AddressView.swift

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ struct AddressView: View {
1414
@Binding var navigationPath: NavigationPath
1515
@State var address: String = ""
1616
@State private var isShowingAlert = false
17+
@State private var alertTitle = "Error"
1718
@State private var alertMessage = ""
19+
@State private var isSweeping = false
20+
private let bdkClient: BDKClient = .live
21+
private let sweepFeeRate: UInt64 = 2
1822
let pasteboard = UIPasteboard.general
1923

2024
var body: some View {
@@ -29,7 +33,7 @@ struct AddressView: View {
2933
)
3034
.alert(isPresented: $isShowingAlert) {
3135
Alert(
32-
title: Text("Error"),
36+
title: Text(alertTitle),
3337
message: Text(alertMessage),
3438
dismissButton: .default(Text("OK"))
3539
)
@@ -45,7 +49,14 @@ extension AddressView {
4549
func handleScan(result: Result<ScanResult, ScanError>) {
4650
switch result {
4751
case .success(let result):
48-
let scannedAddress = result.string.lowercased().replacingOccurrences(
52+
let scannedValue = result.string.trimmingCharacters(in: .whitespacesAndNewlines)
53+
54+
if let wif = WifParser.extract(from: scannedValue) {
55+
sweep(wif: wif)
56+
return
57+
}
58+
59+
let scannedAddress = scannedValue.lowercased().replacingOccurrences(
4960
of: "bitcoin:",
5061
with: ""
5162
)
@@ -54,10 +65,12 @@ extension AddressView {
5465
address = bitcoinAddress
5566
navigationPath.append(NavigationDestination.amount(address: bitcoinAddress))
5667
} else {
68+
alertTitle = "Error"
5769
alertMessage = "The scanned QR code did not contain a valid Bitcoin address."
5870
isShowingAlert = true
5971
}
6072
case .failure(let error):
73+
alertTitle = "Error"
6174
alertMessage = "Scanning failed: \(error.localizedDescription)"
6275
isShowingAlert = true
6376
}
@@ -66,24 +79,68 @@ extension AddressView {
6679
private func pasteAddress() {
6780
if let pasteboardContent = UIPasteboard.general.string {
6881
if pasteboardContent.isEmpty {
82+
alertTitle = "Error"
6983
alertMessage = "The pasteboard is empty."
7084
isShowingAlert = true
7185
return
7286
}
7387
let trimmedContent = pasteboardContent.trimmingCharacters(in: .whitespacesAndNewlines)
7488
if trimmedContent.isEmpty {
89+
alertTitle = "Error"
7590
alertMessage = "The pasteboard contains only whitespace."
7691
isShowingAlert = true
7792
return
7893
}
94+
95+
if let wif = WifParser.extract(from: trimmedContent) {
96+
sweep(wif: wif)
97+
return
98+
}
99+
79100
let lowercaseAddress = trimmedContent.lowercased()
80101
address = lowercaseAddress
81102
navigationPath.append(NavigationDestination.amount(address: address))
82103
} else {
104+
alertTitle = "Error"
83105
alertMessage = "Unable to access the pasteboard. Please try copying the address again."
84106
isShowingAlert = true
85107
}
86108
}
109+
110+
private func sweep(wif: String) {
111+
guard !isSweeping else { return }
112+
isSweeping = true
113+
114+
Task {
115+
defer {
116+
Task { @MainActor in
117+
isSweeping = false
118+
}
119+
}
120+
121+
do {
122+
let txids = try await bdkClient.sweepWif(wif, sweepFeeRate)
123+
let txidText = txids.map { "\($0)" }.joined(separator: ", ")
124+
125+
await MainActor.run {
126+
alertTitle = "Success"
127+
alertMessage = "Sweep broadcasted: \(txidText)"
128+
isShowingAlert = true
129+
NotificationCenter.default.post(
130+
name: Notification.Name("TransactionSent"),
131+
object: nil
132+
)
133+
}
134+
} catch {
135+
await MainActor.run {
136+
alertTitle = "Error"
137+
alertMessage = "Sweep failed: \(error.localizedDescription)"
138+
isShowingAlert = true
139+
}
140+
}
141+
}
142+
}
143+
87144
}
88145

89146
struct CustomScannerView: View {

0 commit comments

Comments
 (0)