// // BitcoinService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt import BitcoinDevKit /// Real Bitcoin support via the Bitcoin Dev Kit (bdk-swift, macOS-native). /// /// Same one-mnemonic model as the EVM side: BIP-84 native-segwit descriptors /// (m/84'/…, bc1q addresses) are derived from the vault mnemonic. The service /// holds a **watch-only** wallet (public descriptors) for sync/balance/ /// receive; sends re-derive the secret descriptors from the vault password, /// sign the PSBT with a throwaway in-memory signer wallet, and discard them. /// /// Networking is keyless Esplora (mempool.space / blockstream.info) — sync, /// fee estimates and broadcast; endpoints follow the research in /// docs/RESEARCH-MULTICHAIN.md. Default network is signet (testnet-first /// rule); mainnet sits behind the same explicit switch pattern as EVM. public actor BitcoinService { public enum BTCNetwork: String, CaseIterable, Codable, Sendable { case mainnet case signet public var bdkNetwork: BitcoinDevKit.Network { switch self { case .mainnet: return .bitcoin case .signet: return .signet } } public var esploraURL: String { switch self { case .mainnet: return "https://mempool.space/api" case .signet: return "https://mempool.space/signet/api" } } public var fallbackEsploraURL: String? { switch self { case .mainnet: return "https://blockstream.info/api" case .signet: return nil } } public var feeAPI: String { switch self { case .mainnet: return "https://mempool.space/api/v1/fees/recommended" case .signet: return "https://mempool.space/signet/api/v1/fees/recommended" } } public func explorerTxURL(_ txid: String) -> URL { switch self { case .mainnet: return URL(string: "https://mempool.space/tx/\(txid)")! case .signet: return URL(string: "https://mempool.space/signet/tx/\(txid)")! } } public func explorerAddressURL(_ address: String) -> URL { switch self { case .mainnet: return URL(string: "https://mempool.space/address/\(address)")! case .signet: return URL(string: "https://mempool.space/signet/address/\(address)")! } } public var displayName: String { switch self { case .mainnet: return "Bitcoin" case .signet: return "Bitcoin Signet" } } public var isTestnet: Bool { self == .signet } } public static let networkKey = "osvault.bitcoin.network" public struct BTCBalance: Sendable { public var confirmedSats: UInt64 = 0 public var pendingSats: UInt64 = 0 public var totalSats: UInt64 { confirmedSats + pendingSats } } public struct FeeRates: Sendable { public let fastest: UInt64 public let halfHour: UInt64 public let hour: UInt64 public let economy: UInt64 } public struct PreparedBTCSend: Sendable { public let recipient: String public let amountSats: UInt64 public let feeSats: UInt64 public let feeRateSatVb: UInt64 public let network: BTCNetwork } private var wallet: Wallet? private var persister: Persister? private var network: BTCNetwork = .signet private var pendingPsbt: Psbt? private var hasFullScanned = false private let dataDir: URL public init(dataDir: URL? = nil) { if let dataDir { self.dataDir = dataDir } else { let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.dataDir = support.appendingPathComponent("OSVault") } } // MARK: - Setup (watch-only, from the vault mnemonic) /// Derives BIP-84 descriptors from the mnemonic, keeps only the public /// side, and opens (or creates) the persisted watch-only wallet. public func configure(mnemonic: String) throws { let stored = UserDefaults.standard.string(forKey: Self.networkKey) network = stored.flatMap(BTCNetwork.init(rawValue:)) ?? .signet let (external, internalD) = try Self.publicDescriptors(mnemonic: mnemonic, network: network) try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true) let dbPath = dataDir.appendingPathComponent("bdk-\(network.rawValue).sqlite").path let store = try Persister.newSqlite(path: dbPath) if let loaded = try? Wallet.load(descriptor: external, changeDescriptor: internalD, persister: store) { wallet = loaded } else { wallet = try Wallet(descriptor: external, changeDescriptor: internalD, network: network.bdkNetwork, persister: store) } persister = store hasFullScanned = UserDefaults.standard.bool(forKey: scanFlagKey) } private var scanFlagKey: String { "osvault.bitcoin.scanned.\(network.rawValue)" } static func secretDescriptors(mnemonic: String, network: BTCNetwork) throws -> (Descriptor, Descriptor) { let parsed = try Mnemonic.fromString(mnemonic: mnemonic) let kind: NetworkKind = network == .mainnet ? .main : .test let key = DescriptorSecretKey(networkKind: kind, mnemonic: parsed, password: nil) let external = Descriptor.newBip84(secretKey: key, keychainKind: .external, networkKind: kind) let internalD = Descriptor.newBip84(secretKey: key, keychainKind: .internal, networkKind: kind) return (external, internalD) } static func publicDescriptors(mnemonic: String, network: BTCNetwork) throws -> (Descriptor, Descriptor) { let (extSecret, intSecret) = try secretDescriptors(mnemonic: mnemonic, network: network) // description renders the PUBLIC form of the descriptor. let kind: NetworkKind = network == .mainnet ? .main : .test let external = try Descriptor(descriptor: extSecret.description, networkKind: kind) let internalD = try Descriptor(descriptor: intSecret.description, networkKind: kind) return (external, internalD) } public var isConfigured: Bool { wallet != nil } public var currentNetwork: BTCNetwork { network } /// Called when the user flips mainnet/signet in Settings. public func switchNetwork(to newNetwork: BTCNetwork, mnemonic: String) throws { UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey) wallet = nil persister = nil try configure(mnemonic: mnemonic) } // MARK: - Sync / balance / receive /// First call does a full descriptor scan (slow, once); later calls only /// check revealed scripts (fast). public func sync() throws { guard let wallet, let persister else { throw WalletError.internalError("Bitcoin wallet not configured.") } let client = EsploraClient(url: network.esploraURL) let update: Update if !hasFullScanned { let request = try wallet.startFullScan().build() update = try client.fullScan(request: request, stopGap: 20, parallelRequests: 4) hasFullScanned = true UserDefaults.standard.set(true, forKey: scanFlagKey) } else { let request = try wallet.startSyncWithRevealedSpks().build() update = try client.sync(request: request, parallelRequests: 4) } try wallet.applyUpdate(update: update) _ = try wallet.persist(persister: persister) } public func balance() -> BTCBalance { guard let wallet else { return BTCBalance() } let b = wallet.balance() return BTCBalance( confirmedSats: b.confirmed.toSat(), pendingSats: b.trustedPending.toSat() + b.untrustedPending.toSat() ) } /// Fresh address per call (privacy); the revealed index is persisted. public func receiveAddress() throws -> String { guard let wallet, let persister else { throw WalletError.internalError("Bitcoin wallet not configured.") } let info = wallet.revealNextAddress(keychain: .external) _ = try wallet.persist(persister: persister) return info.address.description } public static func validate(address: String, network: BTCNetwork) -> Bool { (try? Address(address: address, network: network.bdkNetwork)) != nil } // MARK: - Fees public func recommendedFees() async -> FeeRates { var request = URLRequest(url: URL(string: network.feeAPI)!) request.timeoutInterval = 10 if let (data, _) = try? await URLSession.shared.data(for: request), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Double] { return FeeRates( fastest: UInt64(json["fastestFee"] ?? 2), halfHour: UInt64(json["halfHourFee"] ?? 2), hour: UInt64(json["hourFee"] ?? 1), economy: UInt64(json["economyFee"] ?? 1) ) } return FeeRates(fastest: 3, halfHour: 2, hour: 2, economy: 1) } // MARK: - Send /// Builds the PSBT on the watch-only wallet (coin selection, change, RBF) /// and reports the exact fee. The PSBT is held until sign-and-broadcast. public func prepareSend(to recipient: String, amountSats: UInt64, feeRateSatVb: UInt64, drainAll: Bool = false) throws -> PreparedBTCSend { guard let wallet else { throw WalletError.internalError("Bitcoin wallet not configured.") } guard let address = try? Address(address: recipient, network: network.bdkNetwork) else { throw WalletError.invalidAddress } let rate = try FeeRate.fromSatPerVb(satVb: feeRateSatVb) var builder = TxBuilder().feeRate(feeRate: rate) if drainAll { builder = builder.drainWallet().drainTo(script: address.scriptPubkey()) } else { builder = builder.addRecipient(script: address.scriptPubkey(), amount: Amount.fromSat(satoshi: amountSats)) } let psbt: Psbt do { psbt = try builder.finish(wallet: wallet) } catch { throw WalletError.internalError("Could not build the transaction: \(error.localizedDescription)") } pendingPsbt = psbt let fee = (try? psbt.fee()) ?? 0 return PreparedBTCSend( recipient: recipient, amountSats: amountSats, feeSats: fee, feeRateSatVb: feeRateSatVb, network: network ) } /// Signs the held PSBT with a throwaway in-memory signer wallet derived /// from the mnemonic, broadcasts, then discards everything secret. public func signAndBroadcast(mnemonic: String) throws -> String { guard let wallet, let persister, let psbt = pendingPsbt else { throw WalletError.internalError("Nothing to send.") } let (extSecret, intSecret) = try Self.secretDescriptors(mnemonic: mnemonic, network: network) let signer = try Wallet(descriptor: extSecret, changeDescriptor: intSecret, network: network.bdkNetwork, persister: Persister.newInMemory()) let signed = try signer.sign(psbt: psbt) guard signed else { throw WalletError.signingFailed } let tx = try psbt.extractTx() let client = EsploraClient(url: network.esploraURL) do { try client.broadcast(transaction: tx) } catch { if let fallback = network.fallbackEsploraURL { try EsploraClient(url: fallback).broadcast(transaction: tx) } else { throw WalletError.rpc(error.localizedDescription) } } pendingPsbt = nil // Register our own tx immediately so the balance reflects the spend. _ = try? wallet.persist(persister: persister) return tx.computeTxid().description } public func cancelPending() { pendingPsbt = nil } // MARK: - Formatting public static func formatBTC(_ sats: UInt64) -> String { TokenAmount.format(BigUInt(sats), decimals: 8) } public static func parseBTC(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: 8), units <= BigUInt(UInt64.max) else { return nil } return UInt64(units) } }