// // RPCService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt /// Minimal JSON-RPC client over URLSession with endpoint failover: the /// network's keyless endpoints are tried in order (user override first), a /// failing endpoint is demoted for a cooldown, and node-side errors (reverts, /// underpriced…) are surfaced immediately — only transport problems rotate. /// Every method returns typed `WalletError`s; the app must never crash on RPC /// trouble. public actor RPCService { public let urls: [URL] private let session: URLSession private var nextID = 1 /// Index of the endpoint that most recently worked (sticky primary). private var preferred = 0 private var demotedUntil: [Int: Date] = [:] public init(urls: [URL], session: URLSession = .shared) { precondition(!urls.isEmpty) self.urls = urls self.session = session } public init(url: URL, session: URLSession = .shared) { self.init(urls: [url], session: session) } // MARK: - Core request with failover private func orderedEndpoints() -> [Int] { let now = Date() let healthy = urls.indices.filter { (demotedUntil[$0] ?? .distantPast) < now } let demoted = urls.indices.filter { !healthy.contains($0) } let sorted = healthy.sorted { a, b in (a == preferred ? 0 : 1, a) < (b == preferred ? 0 : 1, b) } return sorted + demoted // demoted endpoints remain the last resort } private func request(method: String, params: [Any]) async throws -> Any { let id = nextID nextID += 1 let body: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": params] let payload = try JSONSerialization.data(withJSONObject: body) var lastError: Error = WalletError.rpc("Unreachable RPC endpoint.") for index in orderedEndpoints() { var req = URLRequest(url: urls[index]) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = payload req.timeoutInterval = 15 for attempt in 0..<2 { if attempt > 0 { try? await Task.sleep(nanoseconds: 500_000_000) } do { let (data, response) = try await session.data(for: req) if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { lastError = WalletError.rpc("HTTP \(http.statusCode) from RPC endpoint.") continue } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { lastError = WalletError.rpc("Malformed RPC response.") continue } if let errorDict = json["error"] as? [String: Any] { let message = (errorDict["message"] as? String) ?? "RPC error" let code = errorDict["code"] as? Int ?? 0 // Rate-limit style errors → try the next endpoint; // genuine node-side errors (revert, nonce, funds) are // not transient and must surface immediately. if code == -32005 || code == -32001 || message.lowercased().contains("rate") { lastError = WalletError.rpc(message) break } throw WalletError.rpc(message) } guard let result = json["result"] else { lastError = WalletError.rpc("RPC response missing result.") continue } preferred = index return result } catch let error as WalletError { throw error } catch { lastError = WalletError.rpc(error.localizedDescription) } } demotedUntil[index] = Date().addingTimeInterval(60) } throw lastError } private func quantity(method: String, params: [Any]) async throws -> BigUInt { guard let hex = try await request(method: method, params: params) as? String, let value = Hex.toBigUInt(hex) else { throw WalletError.rpc("Unexpected result for \(method).") } return value } // MARK: - Ethereum methods public func chainID() async throws -> BigUInt { try await quantity(method: "eth_chainId", params: []) } public func balance(of address: String) async throws -> BigUInt { try await quantity(method: "eth_getBalance", params: [address, "latest"]) } public func call(to contract: String, data: Data) async throws -> String { guard let result = try await request( method: "eth_call", params: [["to": contract, "data": Hex.string(data)], "latest"] ) as? String else { throw WalletError.rpc("Unexpected result for eth_call.") } return result } public func transactionCount(of address: String) async throws -> BigUInt { try await quantity(method: "eth_getTransactionCount", params: [address, "pending"]) } public func estimateGas(from: String, to destination: String, valueWei: BigUInt, data: Data) async throws -> BigUInt { var tx: [String: Any] = ["from": from, "to": destination] if valueWei > 0 { tx["value"] = Hex.quantity(valueWei) } if !data.isEmpty { tx["data"] = Hex.string(data) } return try await quantity(method: "eth_estimateGas", params: [tx]) } public func gasPrice() async throws -> BigUInt { try await quantity(method: "eth_gasPrice", params: []) } public func maxPriorityFeePerGas() async throws -> BigUInt { try await quantity(method: "eth_maxPriorityFeePerGas", params: []) } public func latestBaseFee() async throws -> BigUInt { guard let block = try await request(method: "eth_getBlockByNumber", params: ["latest", false]) as? [String: Any], let hex = block["baseFeePerGas"] as? String, let fee = Hex.toBigUInt(hex) else { throw WalletError.rpc("Latest block has no base fee.") } return fee } public func sendRawTransaction(_ rawHex: String) async throws -> String { guard let hash = try await request(method: "eth_sendRawTransaction", params: [rawHex]) as? String else { throw WalletError.rpc("Broadcast returned no transaction hash.") } return hash } public struct Receipt { public let succeeded: Bool public let blockNumber: BigUInt? } /// nil while the transaction is still pending. public func transactionReceipt(_ hash: String) async throws -> Receipt? { let result = try await request(method: "eth_getTransactionReceipt", params: [hash]) if result is NSNull { return nil } guard let dict = result as? [String: Any], let statusHex = dict["status"] as? String else { return nil } return Receipt( succeeded: Hex.toBigUInt(statusHex) == 1, blockNumber: (dict["blockNumber"] as? String).flatMap(Hex.toBigUInt) ) } }