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: - 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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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