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%
4.8 KB · 130 lines swift
Raw Blame History
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