Files
koins/apps/ios/Koins/Wallet/SwapService.swift
T

179 lines
6.4 KiB
Swift

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) }
}
}