From 4fa1584dfc6356e76115625f1d7e29e65d3db83b Mon Sep 17 00:00:00 2001 From: TZGyn Date: Wed, 7 Oct 2026 21:01:34 +0800 Subject: [PATCH] Swap XMR and XRP through kyc.rip without leaving the app --- apps/ios/Koins/AppModel.swift | 57 ++++ apps/ios/Koins/SelfTest.swift | 20 ++ apps/ios/Koins/Support/WalletStore.swift | 25 ++ apps/ios/Koins/Views/RootView.swift | 5 + apps/ios/Koins/Views/SwapView.swift | 345 +++++++++++++++++++++++ apps/ios/Koins/Wallet/SwapService.swift | 179 ++++++++++++ 6 files changed, 631 insertions(+) create mode 100644 apps/ios/Koins/Views/SwapView.swift create mode 100644 apps/ios/Koins/Wallet/SwapService.swift diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index 32fa10a..f0de3e3 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -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() diff --git a/apps/ios/Koins/SelfTest.swift b/apps/ios/Koins/SelfTest.swift index 5ffccf0..25b5347 100644 --- a/apps/ios/Koins/SelfTest.swift +++ b/apps/ios/Koins/SelfTest.swift @@ -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 diff --git a/apps/ios/Koins/Support/WalletStore.swift b/apps/ios/Koins/Support/WalletStore.swift index 8898874..f9434f8 100644 --- a/apps/ios/Koins/Support/WalletStore.swift +++ b/apps/ios/Koins/Support/WalletStore.swift @@ -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)) } diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index c4d586b..bb44c7e 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -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 { diff --git a/apps/ios/Koins/Views/SwapView.swift b/apps/ios/Koins/Views/SwapView.swift new file mode 100644 index 0000000..d707097 --- /dev/null +++ b/apps/ios/Koins/Views/SwapView.swift @@ -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()) + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Wallet/SwapService.swift b/apps/ios/Koins/Wallet/SwapService.swift new file mode 100644 index 0000000..f5cf655 --- /dev/null +++ b/apps/ios/Koins/Wallet/SwapService.swift @@ -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) } + } +} \ No newline at end of file