import Foundation /// A route offered by one of kyc.rip's underlying exchange engines. struct SwapRoute: Identifiable { var id: String { "\(provider).\(engine).\(amountTo)" } var provider: String var engine: String var amountFrom: Double var amountTo: Double var eta: Int? // minutes var fixedRate: Bool var spread: Double? // percent above/below the market rate var kycRating: String? // A/B/C privacy grade from the aggregator var logPolicy: String? // the provider's logging policy grade var insurance: Int? // percent of the swap insured by the provider var minDeposit: Double? var maxDeposit: Double? var recommended: Bool } struct SwapEstimate { var id: String 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 (refreshed from the API). var status: String? /// Transaction hashes reported by the provider once the swap progresses. var txIn: String? var txOut: String? /// Provider support link, reported by the status endpoint when available. var supportUrl: String? /// Set once the deposit has been broadcast from this device, so the /// "Send deposit" button can never be pressed twice for the same order. var depositSent: Bool? } /// Latest-known state of an order, as reported by the status endpoint. struct SwapOrderUpdate { var status: String var txIn: String? var txOut: String? var supportUrl: 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, kycRating: entry["kyc"] as? String, logPolicy: entry["log_policy"] as? String, insurance: (entry["insurance"] as? NSNumber)?.intValue, minDeposit: (entry["min_deposit"] as? NSNumber)?.doubleValue, maxDeposit: (entry["max_deposit"] as? NSNumber)?.doubleValue, recommended: (entry["recommended"] as? Bool) ?? false) } return SwapEstimate(id: json["id"] as? String ?? "", amountTo: amountTo, routes: routes) } // MARK: - Orders func createOrder(route: SwapRoute, estimateID: String, 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") var 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, "amount_to": route.amountTo, "fixed_rate": route.fixedRate, "source": "swap", "isPayment": false, ] // The create endpoint expects the quote it was estimated against. if !estimateID.isEmpty { body["trade_id"] = estimateID } if let kyc = route.kycRating { body["kyc_rating"] = kyc } if let log = route.logPolicy { body["log_policy"] = log } 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, status: json["status"] as? String, supportUrl: nonEmpty(json["supportUrl"] as? String)) } func orderStatus(_ order: SwapOrder) async throws -> SwapOrderUpdate { 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 = nonEmpty(json["txIn"] as? String) ?? (json["txIn"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) } let txOut = nonEmpty(json["txOut"] as? String) ?? (json["txOut"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) } return SwapOrderUpdate(status: json["status"] as? String ?? "UNKNOWN", txIn: txIn, txOut: txOut, supportUrl: nonEmpty(json["supportUrl"] as? String)) } /// The API sends blank strings while a hash is still unknown; treat those as /// absent so the UI does not render empty rows. private func nonEmpty(_ value: String?) -> String? { guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } return value } 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) } } }