Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions BDKSwiftExampleWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -145,6 +146,7 @@
AE2B8C1C2A9678C900815B2F /* FeeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeeService.swift; sourceTree = "<group>"; };
AE2B8C1E2A96797300815B2F /* RecommendedFees.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendedFees.swift; sourceTree = "<group>"; };
AE2F255C2BED0BFB002A9AC6 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
FC190D44F8616C5DC06AD853 /* WifParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifParser.swift; sourceTree = "<group>"; };
AE34DDAB2B6B31ED00F04AD4 /* WalletRecoveryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletRecoveryView.swift; sourceTree = "<group>"; };
AE34DDAD2B6B320F00F04AD4 /* WalletRecoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletRecoveryViewModel.swift; sourceTree = "<group>"; };
AE3646252BEDB01200B04E25 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -308,6 +310,7 @@
children = (
AE79538D2A2D59F000CCB277 /* Constants.swift */,
AE2F255C2BED0BFB002A9AC6 /* AppError.swift */,
FC190D44F8616C5DC06AD853 /* WifParser.swift */,
);
path = Utilities;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
84 changes: 84 additions & 0 deletions BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: is it intentional to call getAddress() before confirming any candidate WIF wallet has funds?

my thought was getAddress() reveals/persists the next external address, so failed/empty sweeps can still advance the receive index.

Should we defer destination address creation until after the first candidate with non-zero balance is found? Just wondering/thoughts, haven't fully fleshed it out in my head.

also this is non-blocking for merge because it seemed more like a correctness/UX polish issue where no funds are lost and wallet still works the only downside was just consuming a receive address index earlier than necessary on failed sweeps

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, it wasn't intentional. I get what you mean I think.

I guess something lazy like this would fix?

var destinationScript: Script?                     // starting optional nil

// ...

for descriptorString in candidates {
    // ...
    if balance == 0 { continue }                   // don't call getAddress directly 

    if destinationScript == nil {                  // only bump when funds found
        let addr = try getAddress()
        // ...
    }

    // ... build tx with destinationScript
}

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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ enum WalletError: Error {
case walletNotFound
case fullScanUnsupported
case backendNotImplemented
case sweepEsploraOnly
case noSweepableFunds
}

extension WalletError: LocalizedError {
Expand All @@ -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"
}
}
}
57 changes: 57 additions & 0 deletions BDKSwiftExampleWallet/Utilities/WifParser.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
11 changes: 11 additions & 0 deletions BDKSwiftExampleWallet/View Model/OnboardingViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions BDKSwiftExampleWallet/View/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ struct OnboardingView: View {
message: Text(viewModel.onboardingViewError?.description ?? "Unknown"),
dismissButton: .default(Text("OK")) {
viewModel.onboardingViewError = nil
showingOnboardingViewErrorAlert = false
}
)
}
Expand All @@ -251,6 +252,9 @@ struct OnboardingView: View {
animateContent = true
}
}
.onReceive(viewModel.$onboardingViewError) { error in
showingOnboardingViewErrorAlert = (error != nil)
}
}
}

Expand Down
61 changes: 59 additions & 2 deletions BDKSwiftExampleWallet/View/Send/AddressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,7 +33,7 @@ struct AddressView: View {
)
.alert(isPresented: $isShowingAlert) {
Alert(
title: Text("Error"),
title: Text(alertTitle),
message: Text(alertMessage),
dismissButton: .default(Text("OK"))
)
Expand All @@ -45,7 +49,14 @@ extension AddressView {
func handleScan(result: Result<ScanResult, ScanError>) {
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: ""
)
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
Loading