// // AppState.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import SwiftUI import BigInt /// Single source of truth for the UI. Holds the public wallet address and /// balances; never the private key — signing re-derives it from the vault /// password at confirmation time and discards it immediately. @MainActor public final class AppState: ObservableObject { public enum Phase: Equatable { case onboarding case locked case unlocked } @Published public var phase: Phase @Published public var address: String? @Published public var network: Network { didSet { UserDefaults.standard.set(network.rawValue, forKey: Self.networkKey) restartPollingIfUnlocked() } } @Published public var balances = Balances() @Published public var balancesLoaded = false @Published public var balanceError: String? @Published public var history: [TransactionRecord] = [] @Published public var fiatPrices: [String: Decimal] = [:] @Published public var pricesEnabled: Bool { didSet { UserDefaults.standard.set(pricesEnabled, forKey: PriceService.enabledKey) if pricesEnabled { Task { await refreshPrices() } } else { fiatPrices = [:] } } } @Published public var fiatCurrency: String { didSet { UserDefaults.standard.set(fiatCurrency, forKey: PriceService.fiatKey) Task { await refreshPrices() } } } public let priceService = PriceService() public let bitcoinService = BitcoinService() public let solanaService = SolanaService() public let tronService = TronService() public let xrplService = XRPLService() @Published public var xrplBalances: XRPLService.XRPLBalances? @Published public var xrplAddress: String? @Published public var xrplError: String? @Published public var xrplNetwork: XRPLService.XRPLNetwork = .testnet public let tonService = TONService() @Published public var tonBalancesState: TONService.TONBalances? @Published public var tonAddress: String? @Published public var tonError: String? @Published public var tonNetwork: TONService.TONNetwork = .testnet @Published public var tronBalances: TronService.TronBalances? @Published public var tronAddress: String? @Published public var tronError: String? @Published public var tronNetwork: TronService.TronNetwork = .nile @Published public var solBalances: SolanaService.SOLBalances? @Published public var solAddress: String? @Published public var solError: String? @Published public var solNetwork: SolanaService.SOLNetwork = .devnet @Published public var btcBalance: BitcoinService.BTCBalance? @Published public var btcSyncing = false @Published public var btcError: String? @Published public var btcNetwork: BitcoinService.BTCNetwork = .signet public let keyManager: KeyManager let historyStore: HistoryStore private var pollTask: Task? static let networkKey = "osvault.network" public static let pollInterval: Duration = .seconds(15) public init(keyManager: KeyManager = KeyManager(), historyStore: HistoryStore = HistoryStore()) { self.keyManager = keyManager self.historyStore = historyStore let saved = UserDefaults.standard.string(forKey: Self.networkKey) self.network = saved.flatMap(Network.init(rawValue:)) ?? .baseSepolia self.phase = keyManager.hasVault ? .locked : .onboarding self.history = historyStore.load() self.pricesEnabled = UserDefaults.standard.object(forKey: PriceService.enabledKey) as? Bool ?? true self.fiatCurrency = UserDefaults.standard.string(forKey: PriceService.fiatKey) ?? "USD" } public func rpc() -> RPCService { RPCService(urls: network.rpcURLs) } // MARK: - Session public func didUnlock(wallet: KeyManager.UnlockedWallet) { self.address = wallet.address self.phase = .unlocked let mnemonic = wallet.mnemonic Task { do { try await bitcoinService.configure(mnemonic: mnemonic) btcNetwork = await bitcoinService.currentNetwork await refreshBitcoin() } catch { btcError = error.localizedDescription } } Task { do { try await solanaService.configure(mnemonic: mnemonic) solNetwork = await solanaService.currentNetwork solAddress = await solanaService.publicAddress await refreshSolana() } catch { solError = error.localizedDescription } } Task { do { try await tronService.configure(mnemonic: mnemonic) tronNetwork = await tronService.currentNetwork tronAddress = await tronService.publicAddress await refreshTron() } catch { tronError = error.localizedDescription } } Task { do { try await xrplService.configure(mnemonic: mnemonic) xrplNetwork = await xrplService.currentNetwork xrplAddress = await xrplService.publicAddress await refreshXRPL() } catch { xrplError = error.localizedDescription } } Task { do { try await tonService.configure(mnemonic: mnemonic) tonNetwork = await tonService.currentNetwork tonAddress = await tonService.publicAddress await refreshTON() } catch { tonError = error.localizedDescription } } startPolling() } public func refreshBitcoin() async { guard await bitcoinService.isConfigured else { return } btcSyncing = true defer { btcSyncing = false } do { try await bitcoinService.sync() btcBalance = await bitcoinService.balance() btcError = nil } catch { btcError = error.localizedDescription } } public func refreshSolana() async { guard await solanaService.isConfigured else { return } do { solBalances = try await solanaService.fetchBalances() solError = nil } catch { solError = error.localizedDescription } } public func refreshTron() async { guard await tronService.isConfigured else { return } do { tronBalances = try await tronService.fetchBalances() tronError = nil } catch { tronError = error.localizedDescription } } public func switchTronNetwork(to newNetwork: TronService.TronNetwork) async { await tronService.switchNetwork(to: newNetwork) tronNetwork = newNetwork tronBalances = nil await refreshTron() } public func refreshXRPL() async { guard await xrplService.isConfigured else { return } do { xrplBalances = try await xrplService.fetchBalances() xrplError = nil } catch { xrplError = error.localizedDescription } } public func switchXRPLNetwork(to newNetwork: XRPLService.XRPLNetwork) async { await xrplService.switchNetwork(to: newNetwork) xrplNetwork = newNetwork xrplBalances = nil await refreshXRPL() } public func refreshTON() async { guard await tonService.isConfigured else { return } do { tonBalancesState = try await tonService.fetchBalances() tonError = nil } catch { tonError = error.localizedDescription } } public func switchTONNetwork(to newNetwork: TONService.TONNetwork) async { await tonService.switchNetwork(to: newNetwork) tonNetwork = newNetwork tonBalancesState = nil await refreshTON() } public func switchSolanaNetwork(to newNetwork: SolanaService.SOLNetwork) async { await solanaService.switchNetwork(to: newNetwork) solNetwork = newNetwork solBalances = nil await refreshSolana() } /// Re-derives descriptors for the new BTC network; needs the password. public func switchBitcoinNetwork(to newNetwork: BitcoinService.BTCNetwork, password: String) async throws { let mnemonic = try await Task.detached { [keyManager] in try keyManager.unlock(password: password).mnemonic }.value try await bitcoinService.switchNetwork(to: newNetwork, mnemonic: mnemonic) btcNetwork = newNetwork btcBalance = nil await refreshBitcoin() } public func lock() { pollTask?.cancel() pollTask = nil address = nil balances = Balances() balancesLoaded = false phase = .locked } public func walletDeleted() { pollTask?.cancel() pollTask = nil address = nil balances = Balances() balancesLoaded = false history = [] historyStore.clear() btcBalance = nil solBalances = nil solAddress = nil tronBalances = nil tronAddress = nil xrplBalances = nil xrplAddress = nil tonBalancesState = nil tonAddress = nil // Remove the BDK watch-only databases and scan flags with the vault. let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] for network in BitcoinService.BTCNetwork.allCases { try? FileManager.default.removeItem( at: support.appendingPathComponent("OSVault/bdk-\(network.rawValue).sqlite")) UserDefaults.standard.removeObject(forKey: "osvault.bitcoin.scanned.\(network.rawValue)") } phase = .onboarding } // MARK: - Balances public func refreshBalances() async { guard let address else { return } do { let fetched = try await BalanceService.fetch(address: address, network: network, rpc: rpc()) balances = fetched balancesLoaded = true balanceError = nil } catch { balanceError = error.localizedDescription } } public func refreshPrices() async { guard pricesEnabled else { return } let symbols = Token.available(on: network).map(\.symbol) + [network.config.nativeSymbol, "BTC", "SOL", "TRX", "USDT", "XRP", "TON"] fiatPrices = await priceService.prices(for: symbols, fiat: fiatCurrency) } private func startPolling() { pollTask?.cancel() pollTask = Task { [weak self] in var tick = 0 while !Task.isCancelled { await self?.refreshBalances() await self?.refreshPrices() // Bitcoin blocks are ~10 min; sync every 4th tick (60 s). if tick % 4 == 0 { await self?.refreshBitcoin() } if tick % 2 == 0 { await self?.refreshSolana() } if tick % 2 == 1 { await self?.refreshTron() } if tick % 4 == 1 { await self?.refreshXRPL() } if tick % 4 == 3 { await self?.refreshTON() } tick += 1 try? await Task.sleep(for: Self.pollInterval) } } } private func restartPollingIfUnlocked() { guard phase == .unlocked else { return } balances = Balances() balancesLoaded = false startPolling() } // MARK: - History public func recordSend(_ record: TransactionRecord) { history.insert(record, at: 0) historyStore.save(history) } public func updateRecord(hash: String, status: TransactionRecord.Status) { guard let index = history.firstIndex(where: { $0.hash == hash }) else { return } history[index].status = status historyStore.save(history) } }