// // PriceService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt /// Fiat valuation, keyless and optional. One batched CoinGecko call covers /// every asset in USD/CAD/EUR (research: keyless budget 5–15 calls/min; we /// use ~1 per 2 min). Serve-stale-while-revalidate: the UI renders from the /// on-disk cache immediately and never blocks on the network. Prices are a /// display-layer estimate only — balances remain exact base units. public actor PriceService { public static let fiatOptions = ["USD", "CAD", "EUR"] public static let enabledKey = "osvault.prices.enabled" public static let fiatKey = "osvault.prices.fiat" /// Symbol → CoinGecko id. Bridged variants share the canonical id. static let coingeckoIDs: [String: String] = [ "ETH": "ethereum", "POL": "polygon-ecosystem-token", "BNB": "binancecoin", "AVAX": "avalanche-2", "xDAI": "xdai", "BTC": "bitcoin", "SOL": "solana", "TRX": "tron", "XRP": "ripple", "TON": "the-open-network", "USDC": "usd-coin", "USDC.e": "usd-coin", "USDT": "tether", "DAI": "dai", "USDS": "usds", "EURC": "euro-coin" ] struct Cache: Codable { var fetchedAt: Date /// id → fiat code (lowercased) → price var prices: [String: [String: Double]] } private let cacheURL: URL private var cache: Cache? private let session: URLSession private let ttl: TimeInterval = 120 public init(cacheURL: URL? = nil, session: URLSession = .shared) { if let cacheURL { self.cacheURL = cacheURL } else { let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.cacheURL = support.appendingPathComponent("OSVault/prices.json") } self.session = session if let data = try? Data(contentsOf: self.cacheURL), let stored = try? JSONDecoder().decode(Cache.self, from: data) { self.cache = stored } } /// Latest known price per symbol in `fiat`, refreshing in the background /// when stale. Returns cached (possibly stale) values on network failure. public func prices(for symbols: [String], fiat: String) async -> [String: Decimal] { let ids = Set(symbols.compactMap { Self.coingeckoIDs[$0] }) guard !ids.isEmpty else { return [:] } if cache == nil || Date().timeIntervalSince(cache!.fetchedAt) > ttl { await refresh(ids: Set(Self.coingeckoIDs.values)) } guard let cache else { return [:] } var result: [String: Decimal] = [:] for symbol in symbols { if let id = Self.coingeckoIDs[symbol], let value = cache.prices[id]?[fiat.lowercased()] { result[symbol] = Decimal(value) } } return result } public var lastUpdated: Date? { cache?.fetchedAt } private func refresh(ids: Set) async { var components = URLComponents(string: "https://api.coingecko.com/api/v3/simple/price")! components.queryItems = [ URLQueryItem(name: "ids", value: ids.sorted().joined(separator: ",")), URLQueryItem(name: "vs_currencies", value: "usd,cad,eur") ] var request = URLRequest(url: components.url!) request.timeoutInterval = 15 do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200, let json = try JSONSerialization.jsonObject(with: data) as? [String: [String: Double]] else { return // keep stale cache } let fresh = Cache(fetchedAt: Date(), prices: json) cache = fresh let dir = cacheURL.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) if let encoded = try? JSONEncoder().encode(fresh) { try? encoded.write(to: cacheURL, options: [.atomic]) } } catch { // Offline or rate-limited: stale cache keeps serving. } } /// amount (base units) × price → fiat, for display only. public static func fiatValue(units: BigUInt, decimals: Int, price: Decimal) -> Decimal { (Decimal(string: String(units)) ?? 0) / pow(Decimal(10), decimals) * price } public static func formatFiat(_ value: Decimal, currency: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.currencyCode = currency formatter.maximumFractionDigits = 2 return formatter.string(from: value as NSDecimalNumber) ?? "\(value) \(currency)" } }