import Foundation /// A route offered by one of kyc.rip's underlying exchange engines. struct SwapRoute: Identifiable { var id: String { provider + engine } var provider: String var engine: String var amountFrom: Double var amountTo: Double var eta: Int? // minutes var fixedRate: Bool var spread: Double? } struct SwapEstimate { var amountTo: Double var routes: [SwapRoute] } /// A created swap order, persisted so it can be tracked after the app restarts. struct SwapOrder: Codable, Identifiable { var id: String var engine: String var provider: String var depositAddress: String var depositAmount: Double var depositMemo: String? var fromTicker: String var fromAmount: Double var toTicker: String var toAmount: Double var addressTo: String var createdAt: Date var eta: Int? /// Latest-known lifecycle state (not persisted). var status: String? } enum SwapError: LocalizedError { case http(Int) case api(String) case noRoute var errorDescription: String? { switch self { case .http(let code): return "Swap service unavailable (\(code))" case .api(let message): return message case .noRoute: return "No provider offers this swap right now" } } } /// kyc.rip (https://api.kyc.rip), a public no-KYC swap aggregator. No account, /// no API key; the response is used directly. final class SwapService { static let shared = SwapService() private let base = "https://api.kyc.rip" private let session: URLSession private init() { let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 25 session = URLSession(configuration: configuration) } // MARK: - Estimates func estimate(from: String, to: String, amount: Double) async throws -> SwapEstimate { var components = URLComponents(string: "\(base)/v2/exchange/estimate") components?.queryItems = [ URLQueryItem(name: "from", value: from), URLQueryItem(name: "from_net", value: "Mainnet"), URLQueryItem(name: "to", value: to), URLQueryItem(name: "to_net", value: "Mainnet"), URLQueryItem(name: "amount", value: String(amount)), ] guard let url = components?.url else { throw SwapError.noRoute } let (data, response) = try await session.data(from: url) try check(response) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw SwapError.noRoute } if let error = json["error"] as? String { throw SwapError.api(error) } let amountTo = (json["amount_to"] as? NSNumber)?.doubleValue ?? 0 let routes = (json["routes"] as? [[String: Any]] ?? []).map { entry in SwapRoute( provider: entry["provider"] as? String ?? "", engine: entry["engine"] as? String ?? "", amountFrom: (entry["amount_from"] as? NSNumber)?.doubleValue ?? 0, amountTo: (entry["amount_to"] as? NSNumber)?.doubleValue ?? 0, eta: (entry["eta"] as? NSNumber)?.intValue, fixedRate: (entry["fixed"] as? Bool) ?? false, spread: (entry["spread"] as? NSNumber)?.doubleValue) } return SwapEstimate(amountTo: amountTo, routes: routes) } // MARK: - Orders func createOrder(route: SwapRoute, from: String, to: String, addressTo: String, amountFrom: Double) async throws -> SwapOrder { guard let url = URL(string: "\(base)/v2/exchange/create") else { throw SwapError.noRoute } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "provider": route.provider, "engine": route.engine, "from_currency": from, "from_network": "Mainnet", "to_currency": to, "to_network": "Mainnet", "address_to": addressTo, "amount_from": amountFrom, ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) try check(response) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw SwapError.noRoute } if let error = json["error"] as? String { throw SwapError.api(error) } if let code = json["code"] as? String { throw SwapError.api(code) } guard let id = json["id"] as? String, !id.isEmpty else { throw SwapError.noRoute } return SwapOrder( id: id, engine: json["engine"] as? String ?? route.engine, provider: json["provider"] as? String ?? route.provider, depositAddress: json["depositAddress"] as? String ?? "", depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom, depositMemo: json["depositMemo"] as? String, fromTicker: json["fromTicker"] as? String ?? from.uppercased(), fromAmount: (json["fromAmount"] as? NSNumber)?.doubleValue ?? amountFrom, toTicker: json["toTicker"] as? String ?? to.uppercased(), toAmount: (json["toAmount"] as? NSNumber)?.doubleValue ?? 0, addressTo: json["address_user"] as? String ?? addressTo, createdAt: Date(), eta: (json["eta"] as? NSNumber)?.intValue) } func orderStatus(_ order: SwapOrder) async throws -> (status: String, txIn: String?, txOut: String?) { var components = URLComponents(string: "\(base)/v2/exchange/status/\(order.id)") components?.queryItems = [URLQueryItem(name: "engine", value: order.engine)] guard let url = components?.url else { throw SwapError.noRoute } let (data, response) = try await session.data(from: url) try check(response) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw SwapError.noRoute } if let error = json["error"] as? String { throw SwapError.api(error) } let txIn = json["txIn"] as? String ?? (json["txIn"] as? [String: Any]).flatMap { $0["hash"] as? String } let txOut = json["txOut"] as? String ?? (json["txOut"] as? [String: Any]).flatMap { $0["hash"] as? String } return (json["status"] as? String ?? "UNKNOWN", txIn, txOut) } private func check(_ response: URLResponse) throws { guard let http = response as? HTTPURLResponse else { return } if http.statusCode == 429 { throw SwapError.api("Rate limited — try again in a minute") } guard (200..<300).contains(http.statusCode) else { throw SwapError.http(http.statusCode) } } }