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%
11.9 KB · 345 lines swift
Raw Blame History
1//2//  AppState.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation10import SwiftUI11import BigInt1213/// Single source of truth for the UI. Holds the public wallet address and14/// balances; never the private key — signing re-derives it from the vault15/// password at confirmation time and discards it immediately.16@MainActor17public final class AppState: ObservableObject {1819    public enum Phase: Equatable {20        case onboarding21        case locked22        case unlocked23    }2425    @Published public var phase: Phase26    @Published public var address: String?27    @Published public var network: Network {28        didSet {29            UserDefaults.standard.set(network.rawValue, forKey: Self.networkKey)30            restartPollingIfUnlocked()31        }32    }33    @Published public var balances = Balances()34    @Published public var balancesLoaded = false35    @Published public var balanceError: String?36    @Published public var history: [TransactionRecord] = []37    @Published public var fiatPrices: [String: Decimal] = [:]38    @Published public var pricesEnabled: Bool {39        didSet {40            UserDefaults.standard.set(pricesEnabled, forKey: PriceService.enabledKey)41            if pricesEnabled { Task { await refreshPrices() } } else { fiatPrices = [:] }42        }43    }44    @Published public var fiatCurrency: String {45        didSet {46            UserDefaults.standard.set(fiatCurrency, forKey: PriceService.fiatKey)47            Task { await refreshPrices() }48        }49    }5051    public let priceService = PriceService()52    public let bitcoinService = BitcoinService()53    public let solanaService = SolanaService()54    public let tronService = TronService()55    public let xrplService = XRPLService()56    @Published public var xrplBalances: XRPLService.XRPLBalances?57    @Published public var xrplAddress: String?58    @Published public var xrplError: String?59    @Published public var xrplNetwork: XRPLService.XRPLNetwork = .testnet60    public let tonService = TONService()61    @Published public var tonBalancesState: TONService.TONBalances?62    @Published public var tonAddress: String?63    @Published public var tonError: String?64    @Published public var tonNetwork: TONService.TONNetwork = .testnet65    @Published public var tronBalances: TronService.TronBalances?66    @Published public var tronAddress: String?67    @Published public var tronError: String?68    @Published public var tronNetwork: TronService.TronNetwork = .nile69    @Published public var solBalances: SolanaService.SOLBalances?70    @Published public var solAddress: String?71    @Published public var solError: String?72    @Published public var solNetwork: SolanaService.SOLNetwork = .devnet73    @Published public var btcBalance: BitcoinService.BTCBalance?74    @Published public var btcSyncing = false75    @Published public var btcError: String?76    @Published public var btcNetwork: BitcoinService.BTCNetwork = .signet77    public let keyManager: KeyManager78    let historyStore: HistoryStore79    private var pollTask: Task<Void, Never>?8081    static let networkKey = "osvault.network"82    public static let pollInterval: Duration = .seconds(15)8384    public init(keyManager: KeyManager = KeyManager(), historyStore: HistoryStore = HistoryStore()) {85        self.keyManager = keyManager86        self.historyStore = historyStore87        let saved = UserDefaults.standard.string(forKey: Self.networkKey)88        self.network = saved.flatMap(Network.init(rawValue:)) ?? .baseSepolia89        self.phase = keyManager.hasVault ? .locked : .onboarding90        self.history = historyStore.load()91        self.pricesEnabled = UserDefaults.standard.object(forKey: PriceService.enabledKey) as? Bool ?? true92        self.fiatCurrency = UserDefaults.standard.string(forKey: PriceService.fiatKey) ?? "USD"93    }9495    public func rpc() -> RPCService {96        RPCService(urls: network.rpcURLs)97    }9899    // MARK: - Session100101    public func didUnlock(wallet: KeyManager.UnlockedWallet) {102        self.address = wallet.address103        self.phase = .unlocked104        let mnemonic = wallet.mnemonic105        Task {106            do {107                try await bitcoinService.configure(mnemonic: mnemonic)108                btcNetwork = await bitcoinService.currentNetwork109                await refreshBitcoin()110            } catch {111                btcError = error.localizedDescription112            }113        }114        Task {115            do {116                try await solanaService.configure(mnemonic: mnemonic)117                solNetwork = await solanaService.currentNetwork118                solAddress = await solanaService.publicAddress119                await refreshSolana()120            } catch {121                solError = error.localizedDescription122            }123        }124        Task {125            do {126                try await tronService.configure(mnemonic: mnemonic)127                tronNetwork = await tronService.currentNetwork128                tronAddress = await tronService.publicAddress129                await refreshTron()130            } catch {131                tronError = error.localizedDescription132            }133        }134        Task {135            do {136                try await xrplService.configure(mnemonic: mnemonic)137                xrplNetwork = await xrplService.currentNetwork138                xrplAddress = await xrplService.publicAddress139                await refreshXRPL()140            } catch {141                xrplError = error.localizedDescription142            }143        }144        Task {145            do {146                try await tonService.configure(mnemonic: mnemonic)147                tonNetwork = await tonService.currentNetwork148                tonAddress = await tonService.publicAddress149                await refreshTON()150            } catch {151                tonError = error.localizedDescription152            }153        }154        startPolling()155    }156157    public func refreshBitcoin() async {158        guard await bitcoinService.isConfigured else { return }159        btcSyncing = true160        defer { btcSyncing = false }161        do {162            try await bitcoinService.sync()163            btcBalance = await bitcoinService.balance()164            btcError = nil165        } catch {166            btcError = error.localizedDescription167        }168    }169170    public func refreshSolana() async {171        guard await solanaService.isConfigured else { return }172        do {173            solBalances = try await solanaService.fetchBalances()174            solError = nil175        } catch {176            solError = error.localizedDescription177        }178    }179180    public func refreshTron() async {181        guard await tronService.isConfigured else { return }182        do {183            tronBalances = try await tronService.fetchBalances()184            tronError = nil185        } catch {186            tronError = error.localizedDescription187        }188    }189190    public func switchTronNetwork(to newNetwork: TronService.TronNetwork) async {191        await tronService.switchNetwork(to: newNetwork)192        tronNetwork = newNetwork193        tronBalances = nil194        await refreshTron()195    }196197    public func refreshXRPL() async {198        guard await xrplService.isConfigured else { return }199        do {200            xrplBalances = try await xrplService.fetchBalances()201            xrplError = nil202        } catch {203            xrplError = error.localizedDescription204        }205    }206207    public func switchXRPLNetwork(to newNetwork: XRPLService.XRPLNetwork) async {208        await xrplService.switchNetwork(to: newNetwork)209        xrplNetwork = newNetwork210        xrplBalances = nil211        await refreshXRPL()212    }213214    public func refreshTON() async {215        guard await tonService.isConfigured else { return }216        do {217            tonBalancesState = try await tonService.fetchBalances()218            tonError = nil219        } catch {220            tonError = error.localizedDescription221        }222    }223224    public func switchTONNetwork(to newNetwork: TONService.TONNetwork) async {225        await tonService.switchNetwork(to: newNetwork)226        tonNetwork = newNetwork227        tonBalancesState = nil228        await refreshTON()229    }230231    public func switchSolanaNetwork(to newNetwork: SolanaService.SOLNetwork) async {232        await solanaService.switchNetwork(to: newNetwork)233        solNetwork = newNetwork234        solBalances = nil235        await refreshSolana()236    }237238    /// Re-derives descriptors for the new BTC network; needs the password.239    public func switchBitcoinNetwork(to newNetwork: BitcoinService.BTCNetwork, password: String) async throws {240        let mnemonic = try await Task.detached { [keyManager] in241            try keyManager.unlock(password: password).mnemonic242        }.value243        try await bitcoinService.switchNetwork(to: newNetwork, mnemonic: mnemonic)244        btcNetwork = newNetwork245        btcBalance = nil246        await refreshBitcoin()247    }248249    public func lock() {250        pollTask?.cancel()251        pollTask = nil252        address = nil253        balances = Balances()254        balancesLoaded = false255        phase = .locked256    }257258    public func walletDeleted() {259        pollTask?.cancel()260        pollTask = nil261        address = nil262        balances = Balances()263        balancesLoaded = false264        history = []265        historyStore.clear()266        btcBalance = nil267        solBalances = nil268        solAddress = nil269        tronBalances = nil270        tronAddress = nil271        xrplBalances = nil272        xrplAddress = nil273        tonBalancesState = nil274        tonAddress = nil275        // Remove the BDK watch-only databases and scan flags with the vault.276        let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]277        for network in BitcoinService.BTCNetwork.allCases {278            try? FileManager.default.removeItem(279                at: support.appendingPathComponent("OSVault/bdk-\(network.rawValue).sqlite"))280            UserDefaults.standard.removeObject(forKey: "osvault.bitcoin.scanned.\(network.rawValue)")281        }282        phase = .onboarding283    }284285    // MARK: - Balances286287    public func refreshBalances() async {288        guard let address else { return }289        do {290            let fetched = try await BalanceService.fetch(address: address, network: network, rpc: rpc())291            balances = fetched292            balancesLoaded = true293            balanceError = nil294        } catch {295            balanceError = error.localizedDescription296        }297    }298299    public func refreshPrices() async {300        guard pricesEnabled else { return }301        let symbols = Token.available(on: network).map(\.symbol)302            + [network.config.nativeSymbol, "BTC", "SOL", "TRX", "USDT", "XRP", "TON"]303        fiatPrices = await priceService.prices(for: symbols, fiat: fiatCurrency)304    }305306    private func startPolling() {307        pollTask?.cancel()308        pollTask = Task { [weak self] in309            var tick = 0310            while !Task.isCancelled {311                await self?.refreshBalances()312                await self?.refreshPrices()313                // Bitcoin blocks are ~10 min; sync every 4th tick (60 s).314                if tick % 4 == 0 { await self?.refreshBitcoin() }315                if tick % 2 == 0 { await self?.refreshSolana() }316                if tick % 2 == 1 { await self?.refreshTron() }317                if tick % 4 == 1 { await self?.refreshXRPL() }318                if tick % 4 == 3 { await self?.refreshTON() }319                tick += 1320                try? await Task.sleep(for: Self.pollInterval)321            }322        }323    }324325    private func restartPollingIfUnlocked() {326        guard phase == .unlocked else { return }327        balances = Balances()328        balancesLoaded = false329        startPolling()330    }331332    // MARK: - History333334    public func recordSend(_ record: TransactionRecord) {335        history.insert(record, at: 0)336        historyStore.save(history)337    }338339    public func updateRecord(hash: String, status: TransactionRecord.Status) {340        guard let index = history.firstIndex(where: { $0.hash == hash }) else { return }341        history[index].status = status342        historyStore.save(history)343    }344}345