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
78 changes: 78 additions & 0 deletions BDKSwiftExampleWallet/Service/BDK Service/BDKService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,79 @@ 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] = []
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
}

let psbt = try TxBuilder()

Choose a reason for hiding this comment

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

sweepWif intentionally probes several descriptor candidates, but once one candidate has balance, any failure in finish, sign, or broadcast throws out of the whole method and prevents trying the remaining candidates. Since this is a heuristic multi-descriptor sweep, per-candidate failures should probably be isolated and skipped; otherwise one unspendable or unbroadcastable candidate can block sweeping funds from the actual matching descriptor shape.

.drainTo(script: destinationScript)
.drainWallet()
.feeRate(feeRate: FeeRate.fromSatPerVb(satVb: feeRate))
.finish(wallet: sweepWallet)

guard try sweepWallet.sign(psbt: psbt) else {
throw WalletError.notSigned
}

let tx = try psbt.extractTx()
try await self.blockchainClient.broadcast(tx)
sweptTxids.append(tx.computeTxid())
}

guard !sweptTxids.isEmpty else {
throw WalletError.noSweepableFunds
}

return sweptTxids
}

func buildTransaction(address: String, amount: UInt64, feeRate: UInt64) throws
-> Psbt
{
Expand Down Expand Up @@ -768,6 +841,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 +886,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 +951,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"
}
}
}
28 changes: 28 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 self.looksLikeWif(self.words) {

Choose a reason for hiding this comment

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

This guard only checks whether the raw trimmed input itself looks like a WIF. In this same PR, the send flow accepts additional formats like wif:... and URI query params (?wif=...), so those values would still fall through wallet creation here instead of showing the sweep guidance. It would be safer to reuse the same parsing or normalization logic before calling looksLikeWif.

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 All @@ -170,4 +181,21 @@ class OnboardingViewModel: ObservableObject {
}
}
}

private func looksLikeWif(_ value: String) -> Bool {
let token = value.trimmingCharacters(in: .whitespacesAndNewlines)

guard token.count == 51 || token.count == 52 else {
return false
}

guard let first = token.first, "5KL9c".contains(first) else {
return false
}

let base58Charset = CharacterSet(
charactersIn: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
)
return token.unicodeScalars.allSatisfy { base58Charset.contains($0) }
}
}
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
96 changes: 95 additions & 1 deletion BDKSwiftExampleWallet/View/Send/AddressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ struct AddressView: View {
@State var address: String = ""
@State private var isShowingAlert = false
@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 Down Expand Up @@ -45,7 +48,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 = extractWif(from: scannedValue) {
sweep(wif: wif)
return
}

let scannedAddress = scannedValue.lowercased().replacingOccurrences(
of: "bitcoin:",
with: ""
)
Expand Down Expand Up @@ -76,6 +86,12 @@ extension AddressView {
isShowingAlert = true
return
}

if let wif = extractWif(from: trimmedContent) {
sweep(wif: wif)
return
}

let lowercaseAddress = trimmedContent.lowercased()
address = lowercaseAddress
navigationPath.append(NavigationDestination.amount(address: address))
Expand All @@ -84,6 +100,84 @@ extension AddressView {
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 {
alertMessage = "Sweep broadcasted: \(txidText)"

Choose a reason for hiding this comment

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

This success branch sets "Sweep broadcasted: ...", but the alert shown above is still hard-coded to the title "Error". That means a successful sweep is presented as an error dialog. We should either restore a dynamic title or use a separate success presentation.

isShowingAlert = true
NotificationCenter.default.post(
name: Notification.Name("TransactionSent"),
object: nil
)
}
} catch {
await MainActor.run {
alertMessage = "Sweep failed: \(error.localizedDescription)"
isShowingAlert = true
}
}
}
}

func extractWif(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
}

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) }
}
}

struct CustomScannerView: View {
Expand Down
16 changes: 15 additions & 1 deletion BDKSwiftExampleWalletTests/BDKSwiftExampleWalletTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,22 @@
// Created by Matthew Ramsden on 5/22/23.
//

import SwiftUI
import XCTest

@testable import BDKSwiftExampleWallet

final class BDKSwiftExampleWalletTests: XCTestCase {}
final class BDKSwiftExampleWalletTests: XCTestCase {

func testExtractWifDetectsPrefixedWifAndRejectsRandomString() {
let view = AddressView(navigationPath: .constant(NavigationPath()))
let likelyWif = "c" + String(repeating: "1", count: 51)

XCTAssertEqual(view.extractWif(from: "wif:\(likelyWif)"), likelyWif)
XCTAssertNil(
view.extractWif(
from: "12cUi8cuUJRiFmGEu4jCAsonSS1dkVyaD7Aoo6URRiXpmaokikuyM778786"
)
)
}
}