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// PriceService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import BigInt1112/// Fiat valuation, keyless and optional. One batched CoinGecko call covers13/// every asset in USD/CAD/EUR (research: keyless budget 5–15 calls/min; we14/// use ~1 per 2 min). Serve-stale-while-revalidate: the UI renders from the15/// on-disk cache immediately and never blocks on the network. Prices are a16/// display-layer estimate only — balances remain exact base units.17public actor PriceService {1819 public static let fiatOptions = ["USD", "CAD", "EUR"]20 public static let enabledKey = "osvault.prices.enabled"21 public static let fiatKey = "osvault.prices.fiat"2223 /// Symbol → CoinGecko id. Bridged variants share the canonical id.24 static let coingeckoIDs: [String: String] = [25 "ETH": "ethereum",26 "POL": "polygon-ecosystem-token",27 "BNB": "binancecoin",28 "AVAX": "avalanche-2",29 "xDAI": "xdai",30 "BTC": "bitcoin",31 "SOL": "solana",32 "TRX": "tron",33 "XRP": "ripple",34 "TON": "the-open-network",35 "USDC": "usd-coin",36 "USDC.e": "usd-coin",37 "USDT": "tether",38 "DAI": "dai",39 "USDS": "usds",40 "EURC": "euro-coin"41 ]4243 struct Cache: Codable {44 var fetchedAt: Date45 /// id → fiat code (lowercased) → price46 var prices: [String: [String: Double]]47 }4849 private let cacheURL: URL50 private var cache: Cache?51 private let session: URLSession52 private let ttl: TimeInterval = 1205354 public init(cacheURL: URL? = nil, session: URLSession = .shared) {55 if let cacheURL {56 self.cacheURL = cacheURL57 } else {58 let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]59 self.cacheURL = support.appendingPathComponent("OSVault/prices.json")60 }61 self.session = session62 if let data = try? Data(contentsOf: self.cacheURL),63 let stored = try? JSONDecoder().decode(Cache.self, from: data) {64 self.cache = stored65 }66 }6768 /// Latest known price per symbol in `fiat`, refreshing in the background69 /// when stale. Returns cached (possibly stale) values on network failure.70 public func prices(for symbols: [String], fiat: String) async -> [String: Decimal] {71 let ids = Set(symbols.compactMap { Self.coingeckoIDs[$0] })72 guard !ids.isEmpty else { return [:] }7374 if cache == nil || Date().timeIntervalSince(cache!.fetchedAt) > ttl {75 await refresh(ids: Set(Self.coingeckoIDs.values))76 }77 guard let cache else { return [:] }7879 var result: [String: Decimal] = [:]80 for symbol in symbols {81 if let id = Self.coingeckoIDs[symbol],82 let value = cache.prices[id]?[fiat.lowercased()] {83 result[symbol] = Decimal(value)84 }85 }86 return result87 }8889 public var lastUpdated: Date? { cache?.fetchedAt }9091 private func refresh(ids: Set<String>) async {92 var components = URLComponents(string: "https://api.coingecko.com/api/v3/simple/price")!93 components.queryItems = [94 URLQueryItem(name: "ids", value: ids.sorted().joined(separator: ",")),95 URLQueryItem(name: "vs_currencies", value: "usd,cad,eur")96 ]97 var request = URLRequest(url: components.url!)98 request.timeoutInterval = 1599 do {100 let (data, response) = try await session.data(for: request)101 guard let http = response as? HTTPURLResponse, http.statusCode == 200,102 let json = try JSONSerialization.jsonObject(with: data) as? [String: [String: Double]] else {103 return // keep stale cache104 }105 let fresh = Cache(fetchedAt: Date(), prices: json)106 cache = fresh107 let dir = cacheURL.deletingLastPathComponent()108 try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)109 if let encoded = try? JSONEncoder().encode(fresh) {110 try? encoded.write(to: cacheURL, options: [.atomic])111 }112 } catch {113 // Offline or rate-limited: stale cache keeps serving.114 }115 }116117 /// amount (base units) × price → fiat, for display only.118 public static func fiatValue(units: BigUInt, decimals: Int, price: Decimal) -> Decimal {119 (Decimal(string: String(units)) ?? 0) / pow(Decimal(10), decimals) * price120 }121122 public static func formatFiat(_ value: Decimal, currency: String) -> String {123 let formatter = NumberFormatter()124 formatter.numberStyle = .currency125 formatter.currencyCode = currency126 formatter.maximumFractionDigits = 2127 return formatter.string(from: value as NSDecimalNumber) ?? "\(value) \(currency)"128 }129}130