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