232 lines
8.7 KiB
Swift
232 lines
8.7 KiB
Swift
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) }
|
|
}
|
|
} |