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// 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