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// RPCService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import BigInt1112/// Minimal JSON-RPC client over URLSession with endpoint failover: the13/// network's keyless endpoints are tried in order (user override first), a14/// failing endpoint is demoted for a cooldown, and node-side errors (reverts,15/// underpriced…) are surfaced immediately — only transport problems rotate.16/// Every method returns typed `WalletError`s; the app must never crash on RPC17/// trouble.18public actor RPCService {1920 public let urls: [URL]21 private let session: URLSession22 private var nextID = 123 /// Index of the endpoint that most recently worked (sticky primary).24 private var preferred = 025 private var demotedUntil: [Int: Date] = [:]2627 public init(urls: [URL], session: URLSession = .shared) {28 precondition(!urls.isEmpty)29 self.urls = urls30 self.session = session31 }3233 public init(url: URL, session: URLSession = .shared) {34 self.init(urls: [url], session: session)35 }3637 // MARK: - Core request with failover3839 private func orderedEndpoints() -> [Int] {40 let now = Date()41 let healthy = urls.indices.filter { (demotedUntil[$0] ?? .distantPast) < now }42 let demoted = urls.indices.filter { !healthy.contains($0) }43 let sorted = healthy.sorted { a, b in44 (a == preferred ? 0 : 1, a) < (b == preferred ? 0 : 1, b)45 }46 return sorted + demoted // demoted endpoints remain the last resort47 }4849 private func request(method: String, params: [Any]) async throws -> Any {50 let id = nextID51 nextID += 152 let body: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": params]53 let payload = try JSONSerialization.data(withJSONObject: body)5455 var lastError: Error = WalletError.rpc("Unreachable RPC endpoint.")56 for index in orderedEndpoints() {57 var req = URLRequest(url: urls[index])58 req.httpMethod = "POST"59 req.setValue("application/json", forHTTPHeaderField: "Content-Type")60 req.httpBody = payload61 req.timeoutInterval = 156263 for attempt in 0..<2 {64 if attempt > 0 {65 try? await Task.sleep(nanoseconds: 500_000_000)66 }67 do {68 let (data, response) = try await session.data(for: req)69 if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {70 lastError = WalletError.rpc("HTTP \(http.statusCode) from RPC endpoint.")71 continue72 }73 guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {74 lastError = WalletError.rpc("Malformed RPC response.")75 continue76 }77 if let errorDict = json["error"] as? [String: Any] {78 let message = (errorDict["message"] as? String) ?? "RPC error"79 let code = errorDict["code"] as? Int ?? 080 // Rate-limit style errors → try the next endpoint;81 // genuine node-side errors (revert, nonce, funds) are82 // not transient and must surface immediately.83 if code == -32005 || code == -32001 || message.lowercased().contains("rate") {84 lastError = WalletError.rpc(message)85 break86 }87 throw WalletError.rpc(message)88 }89 guard let result = json["result"] else {90 lastError = WalletError.rpc("RPC response missing result.")91 continue92 }93 preferred = index94 return result95 } catch let error as WalletError {96 throw error97 } catch {98 lastError = WalletError.rpc(error.localizedDescription)99 }100 }101 demotedUntil[index] = Date().addingTimeInterval(60)102 }103 throw lastError104 }105106 private func quantity(method: String, params: [Any]) async throws -> BigUInt {107 guard let hex = try await request(method: method, params: params) as? String,108 let value = Hex.toBigUInt(hex) else {109 throw WalletError.rpc("Unexpected result for \(method).")110 }111 return value112 }113114 // MARK: - Ethereum methods115116 public func chainID() async throws -> BigUInt {117 try await quantity(method: "eth_chainId", params: [])118 }119120 public func balance(of address: String) async throws -> BigUInt {121 try await quantity(method: "eth_getBalance", params: [address, "latest"])122 }123124 public func call(to contract: String, data: Data) async throws -> String {125 guard let result = try await request(126 method: "eth_call",127 params: [["to": contract, "data": Hex.string(data)], "latest"]128 ) as? String else {129 throw WalletError.rpc("Unexpected result for eth_call.")130 }131 return result132 }133134 public func transactionCount(of address: String) async throws -> BigUInt {135 try await quantity(method: "eth_getTransactionCount", params: [address, "pending"])136 }137138 public func estimateGas(from: String, to destination: String, valueWei: BigUInt, data: Data) async throws -> BigUInt {139 var tx: [String: Any] = ["from": from, "to": destination]140 if valueWei > 0 { tx["value"] = Hex.quantity(valueWei) }141 if !data.isEmpty { tx["data"] = Hex.string(data) }142 return try await quantity(method: "eth_estimateGas", params: [tx])143 }144145 public func gasPrice() async throws -> BigUInt {146 try await quantity(method: "eth_gasPrice", params: [])147 }148149 public func maxPriorityFeePerGas() async throws -> BigUInt {150 try await quantity(method: "eth_maxPriorityFeePerGas", params: [])151 }152153 public func latestBaseFee() async throws -> BigUInt {154 guard let block = try await request(method: "eth_getBlockByNumber", params: ["latest", false]) as? [String: Any],155 let hex = block["baseFeePerGas"] as? String,156 let fee = Hex.toBigUInt(hex) else {157 throw WalletError.rpc("Latest block has no base fee.")158 }159 return fee160 }161162 public func sendRawTransaction(_ rawHex: String) async throws -> String {163 guard let hash = try await request(method: "eth_sendRawTransaction", params: [rawHex]) as? String else {164 throw WalletError.rpc("Broadcast returned no transaction hash.")165 }166 return hash167 }168169 public struct Receipt {170 public let succeeded: Bool171 public let blockNumber: BigUInt?172 }173174 /// nil while the transaction is still pending.175 public func transactionReceipt(_ hash: String) async throws -> Receipt? {176 let result = try await request(method: "eth_getTransactionReceipt", params: [hash])177 if result is NSNull { return nil }178 guard let dict = result as? [String: Any], let statusHex = dict["status"] as? String else {179 return nil180 }181 return Receipt(182 succeeded: Hex.toBigUInt(statusHex) == 1,183 blockNumber: (dict["blockNumber"] as? String).flatMap(Hex.toBigUInt)184 )185 }186}187