Swap XMR and XRP through kyc.rip without leaving the app

This commit is contained in:
2026-10-07 21:01:34 +08:00
parent 31429e6923
commit 4fa1584dfc
6 changed files with 631 additions and 0 deletions
+57
View File
@@ -93,6 +93,62 @@ final class AppModel: ObservableObject {
// MARK: - Biometric lock // 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 { func authenticate() async {
if await Biometrics.authenticate(reason: "Unlock Koins") { if await Biometrics.authenticate(reason: "Unlock Koins") {
locked = false locked = false
@@ -147,6 +203,7 @@ final class AppModel: ObservableObject {
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable { if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
locked = true locked = true
} }
loadSwapOrders()
showCached(chain) showCached(chain)
await refresh(force: true) await refresh(force: true)
startAutoRefresh() startAutoRefresh()
+20
View File
@@ -34,6 +34,9 @@ enum SelfTest {
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") { if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") {
runNetwork() runNetwork()
} }
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestSwap") {
runSwapCheckIfRequested()
}
} }
/// End-to-end check of the live network paths (XRP RPC + native Monero /// 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() { static func run() {
var failures = 0 var failures = 0
+25
View File
@@ -81,6 +81,31 @@ enum WalletStore {
defaults.set(data, forKey: cacheKey(chain)) 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? { static func secret(_ chain: Chain) -> String? {
Keychain.get(secretKey(chain)) Keychain.get(secretKey(chain))
} }
+5
View File
@@ -8,6 +8,7 @@ enum Route: Equatable {
case send case send
case receive case receive
case accounts case accounts
case swap
} }
struct SeedBox: Identifiable { struct SeedBox: Identifiable {
@@ -26,6 +27,7 @@ struct RootView: View {
if arguments.contains("-koinsScreenAccounts") { return .accounts } if arguments.contains("-koinsScreenAccounts") { return .accounts }
if arguments.contains("-koinsScreenSend") { return .send } if arguments.contains("-koinsScreenSend") { return .send }
if arguments.contains("-koinsScreenReceive") { return .receive } if arguments.contains("-koinsScreenReceive") { return .receive }
if arguments.contains("-koinsScreenSwap") { return .swap }
return .home return .home
} }
@State private var seedBox: SeedBox? @State private var seedBox: SeedBox?
@@ -50,6 +52,8 @@ struct RootView: View {
ReceiveView(route: $route) ReceiveView(route: $route)
case .accounts: case .accounts:
AccountsView(route: $route) AccountsView(route: $route)
case .swap:
SwapView(route: $route)
} }
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -233,6 +237,7 @@ struct HomeView: View {
HStack(spacing: 10) { HStack(spacing: 10) {
PrimaryButton(title: "Send") { route = .send } PrimaryButton(title: "Send") { route = .send }
SecondaryButton(title: "Receive") { route = .receive } SecondaryButton(title: "Receive") { route = .receive }
SecondaryButton(title: "Swap") { route = .swap }
} }
if model.chain == .xmr { if model.chain == .xmr {
+345
View File
@@ -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())
}
}
+179
View File
@@ -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) }
}
}