spb/os-vault Public
Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.
Swift 96%
Shell 3.4%
Makefile 0.6%
1//2// BitcoinService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import BigInt11import BitcoinDevKit1213/// Real Bitcoin support via the Bitcoin Dev Kit (bdk-swift, macOS-native).14///15/// Same one-mnemonic model as the EVM side: BIP-84 native-segwit descriptors16/// (m/84'/…, bc1q addresses) are derived from the vault mnemonic. The service17/// holds a **watch-only** wallet (public descriptors) for sync/balance/18/// receive; sends re-derive the secret descriptors from the vault password,19/// sign the PSBT with a throwaway in-memory signer wallet, and discard them.20///21/// Networking is keyless Esplora (mempool.space / blockstream.info) — sync,22/// fee estimates and broadcast; endpoints follow the research in23/// docs/RESEARCH-MULTICHAIN.md. Default network is signet (testnet-first24/// rule); mainnet sits behind the same explicit switch pattern as EVM.25public actor BitcoinService {2627 public enum BTCNetwork: String, CaseIterable, Codable, Sendable {28 case mainnet29 case signet3031 public var bdkNetwork: BitcoinDevKit.Network {32 switch self {33 case .mainnet: return .bitcoin34 case .signet: return .signet35 }36 }3738 public var esploraURL: String {39 switch self {40 case .mainnet: return "https://mempool.space/api"41 case .signet: return "https://mempool.space/signet/api"42 }43 }4445 public var fallbackEsploraURL: String? {46 switch self {47 case .mainnet: return "https://blockstream.info/api"48 case .signet: return nil49 }50 }5152 public var feeAPI: String {53 switch self {54 case .mainnet: return "https://mempool.space/api/v1/fees/recommended"55 case .signet: return "https://mempool.space/signet/api/v1/fees/recommended"56 }57 }5859 public func explorerTxURL(_ txid: String) -> URL {60 switch self {61 case .mainnet: return URL(string: "https://mempool.space/tx/\(txid)")!62 case .signet: return URL(string: "https://mempool.space/signet/tx/\(txid)")!63 }64 }6566 public func explorerAddressURL(_ address: String) -> URL {67 switch self {68 case .mainnet: return URL(string: "https://mempool.space/address/\(address)")!69 case .signet: return URL(string: "https://mempool.space/signet/address/\(address)")!70 }71 }7273 public var displayName: String {74 switch self {75 case .mainnet: return "Bitcoin"76 case .signet: return "Bitcoin Signet"77 }78 }7980 public var isTestnet: Bool { self == .signet }81 }8283 public static let networkKey = "osvault.bitcoin.network"8485 public struct BTCBalance: Sendable {86 public var confirmedSats: UInt64 = 087 public var pendingSats: UInt64 = 088 public var totalSats: UInt64 { confirmedSats + pendingSats }89 }9091 public struct FeeRates: Sendable {92 public let fastest: UInt6493 public let halfHour: UInt6494 public let hour: UInt6495 public let economy: UInt6496 }9798 public struct PreparedBTCSend: Sendable {99 public let recipient: String100 public let amountSats: UInt64101 public let feeSats: UInt64102 public let feeRateSatVb: UInt64103 public let network: BTCNetwork104 }105106 private var wallet: Wallet?107 private var persister: Persister?108 private var network: BTCNetwork = .signet109 private var pendingPsbt: Psbt?110 private var hasFullScanned = false111 private let dataDir: URL112113 public init(dataDir: URL? = nil) {114 if let dataDir {115 self.dataDir = dataDir116 } else {117 let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]118 self.dataDir = support.appendingPathComponent("OSVault")119 }120 }121122 // MARK: - Setup (watch-only, from the vault mnemonic)123124 /// Derives BIP-84 descriptors from the mnemonic, keeps only the public125 /// side, and opens (or creates) the persisted watch-only wallet.126 public func configure(mnemonic: String) throws {127 let stored = UserDefaults.standard.string(forKey: Self.networkKey)128 network = stored.flatMap(BTCNetwork.init(rawValue:)) ?? .signet129130 let (external, internalD) = try Self.publicDescriptors(mnemonic: mnemonic, network: network)131 try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)132 let dbPath = dataDir.appendingPathComponent("bdk-\(network.rawValue).sqlite").path133134 let store = try Persister.newSqlite(path: dbPath)135 if let loaded = try? Wallet.load(descriptor: external, changeDescriptor: internalD, persister: store) {136 wallet = loaded137 } else {138 wallet = try Wallet(descriptor: external, changeDescriptor: internalD,139 network: network.bdkNetwork, persister: store)140 }141 persister = store142 hasFullScanned = UserDefaults.standard.bool(forKey: scanFlagKey)143 }144145 private var scanFlagKey: String { "osvault.bitcoin.scanned.\(network.rawValue)" }146147 static func secretDescriptors(mnemonic: String, network: BTCNetwork) throws -> (Descriptor, Descriptor) {148 let parsed = try Mnemonic.fromString(mnemonic: mnemonic)149 let kind: NetworkKind = network == .mainnet ? .main : .test150 let key = DescriptorSecretKey(networkKind: kind, mnemonic: parsed, password: nil)151 let external = Descriptor.newBip84(secretKey: key, keychainKind: .external, networkKind: kind)152 let internalD = Descriptor.newBip84(secretKey: key, keychainKind: .internal, networkKind: kind)153 return (external, internalD)154 }155156 static func publicDescriptors(mnemonic: String, network: BTCNetwork) throws -> (Descriptor, Descriptor) {157 let (extSecret, intSecret) = try secretDescriptors(mnemonic: mnemonic, network: network)158 // description renders the PUBLIC form of the descriptor.159 let kind: NetworkKind = network == .mainnet ? .main : .test160 let external = try Descriptor(descriptor: extSecret.description, networkKind: kind)161 let internalD = try Descriptor(descriptor: intSecret.description, networkKind: kind)162 return (external, internalD)163 }164165 public var isConfigured: Bool { wallet != nil }166 public var currentNetwork: BTCNetwork { network }167168 /// Called when the user flips mainnet/signet in Settings.169 public func switchNetwork(to newNetwork: BTCNetwork, mnemonic: String) throws {170 UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey)171 wallet = nil172 persister = nil173 try configure(mnemonic: mnemonic)174 }175176 // MARK: - Sync / balance / receive177178 /// First call does a full descriptor scan (slow, once); later calls only179 /// check revealed scripts (fast).180 public func sync() throws {181 guard let wallet, let persister else { throw WalletError.internalError("Bitcoin wallet not configured.") }182 let client = EsploraClient(url: network.esploraURL)183 let update: Update184 if !hasFullScanned {185 let request = try wallet.startFullScan().build()186 update = try client.fullScan(request: request, stopGap: 20, parallelRequests: 4)187 hasFullScanned = true188 UserDefaults.standard.set(true, forKey: scanFlagKey)189 } else {190 let request = try wallet.startSyncWithRevealedSpks().build()191 update = try client.sync(request: request, parallelRequests: 4)192 }193 try wallet.applyUpdate(update: update)194 _ = try wallet.persist(persister: persister)195 }196197 public func balance() -> BTCBalance {198 guard let wallet else { return BTCBalance() }199 let b = wallet.balance()200 return BTCBalance(201 confirmedSats: b.confirmed.toSat(),202 pendingSats: b.trustedPending.toSat() + b.untrustedPending.toSat()203 )204 }205206 /// Fresh address per call (privacy); the revealed index is persisted.207 public func receiveAddress() throws -> String {208 guard let wallet, let persister else { throw WalletError.internalError("Bitcoin wallet not configured.") }209 let info = wallet.revealNextAddress(keychain: .external)210 _ = try wallet.persist(persister: persister)211 return info.address.description212 }213214 public static func validate(address: String, network: BTCNetwork) -> Bool {215 (try? Address(address: address, network: network.bdkNetwork)) != nil216 }217218 // MARK: - Fees219220 public func recommendedFees() async -> FeeRates {221 var request = URLRequest(url: URL(string: network.feeAPI)!)222 request.timeoutInterval = 10223 if let (data, _) = try? await URLSession.shared.data(for: request),224 let json = try? JSONSerialization.jsonObject(with: data) as? [String: Double] {225 return FeeRates(226 fastest: UInt64(json["fastestFee"] ?? 2),227 halfHour: UInt64(json["halfHourFee"] ?? 2),228 hour: UInt64(json["hourFee"] ?? 1),229 economy: UInt64(json["economyFee"] ?? 1)230 )231 }232 return FeeRates(fastest: 3, halfHour: 2, hour: 2, economy: 1)233 }234235 // MARK: - Send236237 /// Builds the PSBT on the watch-only wallet (coin selection, change, RBF)238 /// and reports the exact fee. The PSBT is held until sign-and-broadcast.239 public func prepareSend(to recipient: String, amountSats: UInt64,240 feeRateSatVb: UInt64, drainAll: Bool = false) throws -> PreparedBTCSend {241 guard let wallet else { throw WalletError.internalError("Bitcoin wallet not configured.") }242 guard let address = try? Address(address: recipient, network: network.bdkNetwork) else {243 throw WalletError.invalidAddress244 }245 let rate = try FeeRate.fromSatPerVb(satVb: feeRateSatVb)246 var builder = TxBuilder().feeRate(feeRate: rate)247 if drainAll {248 builder = builder.drainWallet().drainTo(script: address.scriptPubkey())249 } else {250 builder = builder.addRecipient(script: address.scriptPubkey(),251 amount: Amount.fromSat(satoshi: amountSats))252 }253 let psbt: Psbt254 do {255 psbt = try builder.finish(wallet: wallet)256 } catch {257 throw WalletError.internalError("Could not build the transaction: \(error.localizedDescription)")258 }259 pendingPsbt = psbt260 let fee = (try? psbt.fee()) ?? 0261 return PreparedBTCSend(262 recipient: recipient, amountSats: amountSats,263 feeSats: fee, feeRateSatVb: feeRateSatVb, network: network264 )265 }266267 /// Signs the held PSBT with a throwaway in-memory signer wallet derived268 /// from the mnemonic, broadcasts, then discards everything secret.269 public func signAndBroadcast(mnemonic: String) throws -> String {270 guard let wallet, let persister, let psbt = pendingPsbt else {271 throw WalletError.internalError("Nothing to send.")272 }273 let (extSecret, intSecret) = try Self.secretDescriptors(mnemonic: mnemonic, network: network)274 let signer = try Wallet(descriptor: extSecret, changeDescriptor: intSecret,275 network: network.bdkNetwork, persister: Persister.newInMemory())276 let signed = try signer.sign(psbt: psbt)277 guard signed else { throw WalletError.signingFailed }278279 let tx = try psbt.extractTx()280 let client = EsploraClient(url: network.esploraURL)281 do {282 try client.broadcast(transaction: tx)283 } catch {284 if let fallback = network.fallbackEsploraURL {285 try EsploraClient(url: fallback).broadcast(transaction: tx)286 } else {287 throw WalletError.rpc(error.localizedDescription)288 }289 }290 pendingPsbt = nil291 // Register our own tx immediately so the balance reflects the spend.292 _ = try? wallet.persist(persister: persister)293 return tx.computeTxid().description294 }295296 public func cancelPending() {297 pendingPsbt = nil298 }299300 // MARK: - Formatting301302 public static func formatBTC(_ sats: UInt64) -> String {303 TokenAmount.format(BigUInt(sats), decimals: 8)304 }305306 public static func parseBTC(_ input: String) -> UInt64? {307 guard let units = TokenAmount.parse(input, decimals: 8), units <= BigUInt(UInt64.max) else { return nil }308 return UInt64(units)309 }310}311