Swap XMR and XRP through kyc.rip without leaving the app
This commit is contained in:
@@ -93,6 +93,62 @@ final class AppModel: ObservableObject {
|
||||
|
||||
// MARK: - Biometric lock
|
||||
|
||||
// MARK: - Swaps (kyc.rip)
|
||||
|
||||
@Published var swapOrders: [SwapOrder] = []
|
||||
|
||||
func loadSwapOrders() {
|
||||
swapOrders = WalletStore.swapOrders()
|
||||
}
|
||||
|
||||
func estimateSwap(amount: String, from: Chain, to: Chain) async throws -> SwapEstimate {
|
||||
guard let amountNumber = Double(amount.trimmingCharacters(in: .whitespaces)),
|
||||
amountNumber > 0 else {
|
||||
throw SwapError.api("Enter a valid amount")
|
||||
}
|
||||
return try await SwapService.shared.estimate(from: from.rawValue, to: to.rawValue, amount: amountNumber)
|
||||
}
|
||||
|
||||
func createSwap(route: SwapRoute, amountFrom: String, from: Chain, to: Chain) async throws -> SwapOrder {
|
||||
guard let amountNumber = Double(amountFrom.trimmingCharacters(in: .whitespaces)), amountNumber > 0 else {
|
||||
throw SwapError.api("Enter a valid amount")
|
||||
}
|
||||
guard let destination = (to == .xrp ? xrpWallet?.address : xmrWallet?.address), !destination.isEmpty else {
|
||||
throw SwapError.api("No \(to.title) wallet to receive into")
|
||||
}
|
||||
let order = try await SwapService.shared.createOrder(route: route,
|
||||
from: from.rawValue,
|
||||
to: to.rawValue,
|
||||
addressTo: destination,
|
||||
amountFrom: amountNumber)
|
||||
WalletStore.saveSwapOrder(order)
|
||||
loadSwapOrders()
|
||||
return order
|
||||
}
|
||||
|
||||
/// Sends the swap deposit using the same payment path as a normal send.
|
||||
func sendSwapDeposit(_ order: SwapOrder) async throws {
|
||||
try await send(to: order.depositAddress,
|
||||
amount: String(order.depositAmount),
|
||||
tag: order.depositMemo ?? "",
|
||||
priority: .defaultPriority)
|
||||
}
|
||||
|
||||
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
|
||||
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
|
||||
var order = order
|
||||
order.status = updated.status
|
||||
if let index = swapOrders.firstIndex(where: { $0.id == order.id }) {
|
||||
swapOrders[index] = order
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
func removeSwapOrder(id: String) {
|
||||
WalletStore.removeSwapOrder(id: id)
|
||||
loadSwapOrders()
|
||||
}
|
||||
|
||||
func authenticate() async {
|
||||
if await Biometrics.authenticate(reason: "Unlock Koins") {
|
||||
locked = false
|
||||
@@ -147,6 +203,7 @@ final class AppModel: ObservableObject {
|
||||
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
||||
locked = true
|
||||
}
|
||||
loadSwapOrders()
|
||||
showCached(chain)
|
||||
await refresh(force: true)
|
||||
startAutoRefresh()
|
||||
|
||||
@@ -34,6 +34,9 @@ enum SelfTest {
|
||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
|
||||
runNetwork()
|
||||
}
|
||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
|
||||
runSwapCheckIfRequested()
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end check of the live network paths (XRP RPC + native Monero
|
||||
@@ -113,6 +116,23 @@ enum SelfTest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary: exercise the kyc.rip estimate path with real values.
|
||||
static func runSwapCheckIfRequested() {
|
||||
guard ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let estimate = try await SwapService.shared.estimate(from: "xmr", to: "xrp", amount: 1)
|
||||
emit("KOINS_SWAP amountTo \(estimate.amountTo)")
|
||||
for route in estimate.routes.prefix(3) {
|
||||
emit("KOINS_SWAP route \(route.provider) (\(route.engine)) -> \(route.amountTo) eta \(route.eta ?? -1)")
|
||||
}
|
||||
} catch {
|
||||
emit("KOINS_SWAP FAIL \(error)")
|
||||
}
|
||||
flushReport()
|
||||
}
|
||||
}
|
||||
|
||||
static func run() {
|
||||
var failures = 0
|
||||
|
||||
|
||||
@@ -81,6 +81,31 @@ enum WalletStore {
|
||||
defaults.set(data, forKey: cacheKey(chain))
|
||||
}
|
||||
|
||||
// MARK: - Swap orders (kyc.rip)
|
||||
|
||||
private static let swapOrdersKey = "koins.swapOrders"
|
||||
|
||||
static func swapOrders() -> [SwapOrder] {
|
||||
guard let data = defaults.data(forKey: swapOrdersKey),
|
||||
let orders = try? JSONDecoder().decode([SwapOrder].self, from: data) else { return [] }
|
||||
return orders
|
||||
}
|
||||
|
||||
static func saveSwapOrder(_ order: SwapOrder) {
|
||||
var orders = swapOrders()
|
||||
orders.removeAll { $0.id == order.id }
|
||||
orders.insert(order, at: 0)
|
||||
guard let data = try? JSONEncoder().encode(orders) else { return }
|
||||
defaults.set(data, forKey: swapOrdersKey)
|
||||
}
|
||||
|
||||
static func removeSwapOrder(id: String) {
|
||||
var orders = swapOrders()
|
||||
orders.removeAll { $0.id == id }
|
||||
guard let data = try? JSONEncoder().encode(orders) else { return }
|
||||
defaults.set(data, forKey: swapOrdersKey)
|
||||
}
|
||||
|
||||
static func secret(_ chain: Chain) -> String? {
|
||||
Keychain.get(secretKey(chain))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ enum Route: Equatable {
|
||||
case send
|
||||
case receive
|
||||
case accounts
|
||||
case swap
|
||||
}
|
||||
|
||||
struct SeedBox: Identifiable {
|
||||
@@ -26,6 +27,7 @@ struct RootView: View {
|
||||
if arguments.contains("-koinsScreenAccounts") { return .accounts }
|
||||
if arguments.contains("-koinsScreenSend") { return .send }
|
||||
if arguments.contains("-koinsScreenReceive") { return .receive }
|
||||
if arguments.contains("-koinsScreenSwap") { return .swap }
|
||||
return .home
|
||||
}
|
||||
@State private var seedBox: SeedBox?
|
||||
@@ -50,6 +52,8 @@ struct RootView: View {
|
||||
ReceiveView(route: $route)
|
||||
case .accounts:
|
||||
AccountsView(route: $route)
|
||||
case .swap:
|
||||
SwapView(route: $route)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -233,6 +237,7 @@ struct HomeView: View {
|
||||
HStack(spacing: 10) {
|
||||
PrimaryButton(title: "Send") { route = .send }
|
||||
SecondaryButton(title: "Receive") { route = .receive }
|
||||
SecondaryButton(title: "Swap") { route = .swap }
|
||||
}
|
||||
|
||||
if model.chain == .xmr {
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
|
||||
struct SwapView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@Binding var route: Route
|
||||
|
||||
@State private var from: Chain = .xrp
|
||||
@State private var amount = ""
|
||||
@State private var estimate: SwapEstimate?
|
||||
@State private var estimating = false
|
||||
@State private var creating = false
|
||||
@State private var sendingDeposit = false
|
||||
@State private var createdOrder: SwapOrder?
|
||||
@State private var localError: String?
|
||||
@State private var qrAddress: AddressQR?
|
||||
|
||||
private var to: Chain { from == .xrp ? .xmr : .xrp }
|
||||
|
||||
private var fromBalance: Balance {
|
||||
if from == model.chain { return model.balance }
|
||||
return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false)
|
||||
}
|
||||
|
||||
private var fromBalanceText: String {
|
||||
let value = Decimal(string: fromBalance.total) ?? 0
|
||||
let formatter = NumberFormatter()
|
||||
formatter.maximumFractionDigits = 6
|
||||
return formatter.string(from: value as NSDecimalNumber) ?? "0"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ScreenHeader(title: "Swap",
|
||||
subtitle: "XMR ↔ XRP, via kyc.rip — no account, no KYC",
|
||||
onBack: { route = .home })
|
||||
|
||||
if let order = createdOrder {
|
||||
orderCard(order)
|
||||
} else {
|
||||
swapForm
|
||||
}
|
||||
|
||||
if !model.swapOrders.isEmpty {
|
||||
recentSwaps
|
||||
}
|
||||
}
|
||||
.task {
|
||||
from = model.chain
|
||||
model.loadSwapOrders()
|
||||
}
|
||||
.bottomDrawer(isPresented: Binding(
|
||||
get: { qrAddress != nil },
|
||||
set: { if !$0 { qrAddress = nil } }
|
||||
)) {
|
||||
if let item = qrAddress {
|
||||
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The form
|
||||
|
||||
private var swapForm: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("From")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
Text("Balance: \(fromBalanceText) \(from.symbol)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
KoinsTextField(placeholder: "Amount", text: $amount, keyboard: .decimalPad)
|
||||
Button("Max") { amount = fromBalanceText }
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Text(from.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
Button {
|
||||
from = to
|
||||
amount = ""
|
||||
estimate = nil
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(Theme.surfaceAlt)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityLabel("Swap direction")
|
||||
}
|
||||
|
||||
Divider().overlay(Theme.border)
|
||||
|
||||
HStack {
|
||||
Text("To")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
Text(to.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
|
||||
if let estimate {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("≈ \(String(format: "%.6f", estimate.amountTo)) \(to.symbol)")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.text)
|
||||
if let route = estimate.routes.first {
|
||||
Text("via \(route.provider)\(route.eta.map { " · ~\($0) min" } ?? "")")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Enter an amount and get a quote")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let localError {
|
||||
MessageBanner(message: localError, isError: true)
|
||||
}
|
||||
if let status = model.statusMessage {
|
||||
MessageBanner(message: status, isError: false)
|
||||
}
|
||||
|
||||
if estimate == nil {
|
||||
PrimaryButton(title: estimating ? "Getting quote…" : "Get quote", disabled: estimating) {
|
||||
Task { await loadQuote() }
|
||||
}
|
||||
} else {
|
||||
PrimaryButton(title: creating ? "Creating…" : "Swap", disabled: creating) {
|
||||
Task { await create() }
|
||||
}
|
||||
SecondaryButton(title: "Change amount") { estimate = nil }
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The order
|
||||
|
||||
private func orderCard(_ order: SwapOrder) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Swap deposit")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.text)
|
||||
Spacer()
|
||||
SwapStatusBadge(status: order.status ?? "WAITING")
|
||||
}
|
||||
|
||||
Text("Send exactly \(order.depositAmount, specifier: "%.6g") \(order.fromTicker) to this address:")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.muted)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(order.depositAddress)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Button {
|
||||
qrAddress = AddressQR(title: "Deposit \(order.fromTicker)", address: order.depositAddress)
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(Theme.surfaceAlt)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.disabled(order.depositAddress.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Theme.surfaceAlt)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
|
||||
if let memo = order.depositMemo, !memo.isEmpty {
|
||||
HStack {
|
||||
Text("Tag")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
Text(memo)
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("You get")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
Text("≈ \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.success)
|
||||
}
|
||||
|
||||
if let eta = order.eta {
|
||||
HStack {
|
||||
Text("Usually takes")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.muted)
|
||||
Spacer()
|
||||
Text("~\(eta) min")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) {
|
||||
Task { await sendDeposit(order) }
|
||||
}
|
||||
|
||||
SecondaryButton(title: "Refresh status") {
|
||||
Task { _ = await model.refreshSwapStatus(order) }
|
||||
}
|
||||
|
||||
if let localError {
|
||||
MessageBanner(message: localError, isError: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var recentSwaps: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Recent swaps")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.muted)
|
||||
|
||||
ForEach(model.swapOrders.prefix(6)) { order in
|
||||
Button {
|
||||
createdOrder = order
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(order.fromAmount, specifier: "%.6g") \(order.fromTicker) → \(order.toAmount, specifier: "%.6g") \(order.toTicker)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Theme.text)
|
||||
Text(order.createdAt, style: .date)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.muted)
|
||||
}
|
||||
Spacer()
|
||||
SwapStatusBadge(status: order.status ?? "WAITING")
|
||||
}
|
||||
.padding(12)
|
||||
.background(Theme.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadQuote() async {
|
||||
localError = nil
|
||||
estimating = true
|
||||
defer { estimating = false }
|
||||
do {
|
||||
estimate = try await model.estimateSwap(amount: amount, from: from, to: to)
|
||||
if estimate?.routes.isEmpty == true {
|
||||
localError = "No provider offers this swap right now"
|
||||
estimate = nil
|
||||
}
|
||||
} catch {
|
||||
localError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard let route = estimate?.routes.first else { return }
|
||||
localError = nil
|
||||
creating = true
|
||||
defer { creating = false }
|
||||
do {
|
||||
createdOrder = try await model.createSwap(route: route, amountFrom: amount, from: from, to: to)
|
||||
} catch {
|
||||
localError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func sendDeposit(_ order: SwapOrder) async {
|
||||
localError = nil
|
||||
sendingDeposit = true
|
||||
defer { sendingDeposit = false }
|
||||
do {
|
||||
try await model.sendSwapDeposit(order)
|
||||
await model.refresh(force: true)
|
||||
} catch {
|
||||
localError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SwapStatusBadge: View {
|
||||
var status: String
|
||||
|
||||
private var colour: Color {
|
||||
switch status {
|
||||
case "FINISHED": return Theme.success
|
||||
case "WAITING", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
|
||||
default: return Theme.danger
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(status.lowercased().capitalized)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(colour)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(colour.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user