SPB Git

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%
12.4 KB · 311 lines swift
Raw Blame History
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