import SwiftUI import UIKit /// Swap between the two wallets via kyc.rip, the public no-KYC aggregator. struct SwapView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var drawers: DrawerCoordinator @Binding var route: Route @State private var from: Chain = .xrp @State private var amount = "" @State private var estimate: SwapEstimate? @State private var selectedRouteID: String? @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 xmrAccountIndex = 0 @State private var xmrAccountPicked = false @State private var confirmRemoveOrder = false private var to: Chain { from == .xrp ? .xmr : .xrp } private var selectedXmrAccount: MoneroAccount? { model.moneroAccounts.first(where: { $0.index == xmrAccountIndex }) ?? model.moneroAccounts.first } private var fromBalance: Balance { if from == .xmr, let account = selectedXmrAccount { return Balance(total: account.balance, unlocked: account.unlocked, funded: (Decimal(string: account.balance) ?? 0) > 0) } 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.numberStyle = .decimal formatter.usesGroupingSeparator = false // Monero has 12 decimals; rounding a balance up here would make "Max" // exceed what the account can spend. formatter.maximumFractionDigits = from == .xmr ? 12 : 6 return formatter.string(from: value as NSDecimalNumber) ?? "0" } /// Quoted routes worth considering, best payout first. private var routes: [SwapRoute] { (estimate?.routes ?? []).filter { $0.amountTo > 0 }.sorted { $0.amountTo > $1.amountTo } } private var selectedRoute: SwapRoute? { routes.first { $0.id == selectedRouteID } ?? routes.first } /// One-line summary of everything kyc.rip knows about a route. private func routeFacts(_ route: SwapRoute) -> String { var facts: [String] = [] if let eta = route.eta { facts.append("~\(eta) min") } facts.append(route.fixedRate ? "fixed rate" : "floating rate") if let spread = route.spread { facts.append(String(format: "spread %+.2f%%", spread)) } if let kyc = route.kycRating { facts.append("KYC \(kyc)") } if let log = route.logPolicy { facts.append("logs \(log)") } if let insurance = route.insurance, insurance > 0 { facts.append("insured \(insurance)%") } if let min = route.minDeposit { facts.append("min \(trim(min)) \(from.symbol)") } if let max = route.maxDeposit { facts.append("max \(trim(max)) \(from.symbol)") } return facts.joined(separator: " · ") } private func rowFacts(_ route: SwapRoute) -> String { var facts: [String] = [] if let spread = route.spread { facts.append(String(format: "%+.2f%%", spread)) } if let kyc = route.kycRating { facts.append("KYC \(kyc)") } facts.append(route.fixedRate ? "fixed" : "float") return facts.joined(separator: " · ") } private func trim(_ value: Double) -> String { String(format: "%.6g", value) } // MARK: - Swap status private func statusTitle(_ status: String) -> String { switch status.uppercased() { case "WAITING", "NEW": return "Waiting for your deposit" case "CONFIRMING": return "Deposit detected" case "EXCHANGING": return "Exchanging" case "SENDING": return "Sending to your wallet" case "FINISHED", "SUCCESS": return "Swap complete" case "EXPIRED": return "Order expired" case "FAILED": return "Swap failed" case "REFUNDED": return "Refunded" case "OVERDUE": return "Deposit overdue" default: return "Swap status" } } private func statusDetail(_ status: String, order: SwapOrder) -> String { switch status.uppercased() { case "WAITING", "NEW": return "Send your \(order.fromTicker) deposit to the address below." case "CONFIRMING": return "Your \(order.fromTicker) arrived. Waiting for network confirmations." case "EXCHANGING": return "The provider is converting \(order.fromTicker) to \(order.toTicker)." case "SENDING": return "The provider is sending \(order.toTicker) to your wallet." case "FINISHED", "SUCCESS": return "\(order.toTicker) was sent to your wallet." case "EXPIRED": return "The deposit window closed. If you already sent funds, contact \(order.provider) with the order ID." case "FAILED": return "The provider could not complete this swap. Check the order with \(order.provider)." case "REFUNDED": return "The provider returned your funds to the refund address." case "OVERDUE": return "The deposit arrived after the window closed. Check the order with \(order.provider)." default: return "Last known status from \(order.provider)." } } /// Position in the five-step lifecycle, or nil for failure states. private func statusStep(_ status: String) -> Int? { switch status.uppercased() { case "WAITING", "NEW": return 0 case "CONFIRMING": return 1 case "EXCHANGING": return 2 case "SENDING": return 3 case "FINISHED", "SUCCESS": return 4 default: return nil } } private func isTerminal(_ status: String?) -> Bool { guard let status else { return false } return ["FINISHED", "SUCCESS", "FAILED", "EXPIRED", "REFUNDED", "OVERDUE"] .contains(status.uppercased()) } private func needsDeposit(_ status: String) -> Bool { ["WAITING", "NEW"].contains(status.uppercased()) } /// Refreshes an open order immediately, then every 20 seconds until it /// reaches a final state. Cancelled automatically when the view goes away. private func monitorOrder() async { guard let order = createdOrder else { return } if !isTerminal(order.status) { createdOrder = await model.refreshSwapStatus(order) } while !Task.isCancelled { try? await Task.sleep(nanoseconds: 20 * 1_000_000_000) guard !Task.isCancelled, let current = createdOrder, !isTerminal(current.status) else { return } createdOrder = await model.refreshSwapStatus(current) } } 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 if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex } model.loadSwapOrders() } .task(id: createdOrder?.id) { await monitorOrder() } .onChange(of: model.moneroAccounts.count) { _ in if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex } } } // 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") } if from == .xmr, model.moneroAccounts.count > 1 { MoneroAccountPicker(accounts: model.moneroAccounts, selection: $xmrAccountIndex, onSelect: { xmrAccountPicked = true }) } 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", selectedRoute?.amountTo ?? estimate.amountTo)) \(to.symbol)") .font(.system(size: 22, weight: .bold, design: .rounded)) .foregroundStyle(Theme.text) if let route = selectedRoute { Text(routeFacts(route)) .font(.system(size: 11)) .foregroundStyle(Theme.muted) } } if !routes.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Pick a provider") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.muted) ForEach(routes.prefix(4)) { route in routeRow(route) } } .padding(.top, 6) } } 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) } } private func routeRow(_ route: SwapRoute) -> some View { let selected = route.id == selectedRoute?.id return Button { selectedRouteID = route.id } label: { HStack(spacing: 10) { Image(systemName: selected ? "checkmark.circle.fill" : "circle") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(selected ? Theme.accent : Theme.muted) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(route.provider) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.text) if route.recommended { Text("Recommended") .font(.system(size: 9, weight: .bold)) .foregroundStyle(Theme.accent) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Theme.accentSoft) .clipShape(Capsule()) } } Text(rowFacts(route)) .font(.system(size: 10)) .foregroundStyle(Theme.muted) } Spacer() VStack(alignment: .trailing, spacing: 2) { Text(String(format: "%.6f", route.amountTo)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(Theme.text) Text(route.eta.map { "~\($0) min" } ?? "") .font(.system(size: 10)) .foregroundStyle(Theme.muted) } } .padding(10) .background(selected ? Theme.accentSoft : Theme.surface) .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(selected ? Theme.accent.opacity(0.6) : Theme.border)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) } // MARK: - The order private func orderCard(_ order: SwapOrder) -> some View { let status = order.status ?? "WAITING" let alreadySent = order.depositSent == true let canSend = needsDeposit(status) && !alreadySent return VStack(alignment: .leading, spacing: 14) { Card { VStack(alignment: .leading, spacing: 10) { HStack { Text(statusTitle(status)) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.text) Spacer() SwapStatusBadge(status: status) } Text(alreadySent && needsDeposit(status) ? "Deposit sent — waiting for \(order.provider) to see it." : statusDetail(status, order: order)) .font(.system(size: 12)) .foregroundStyle(Theme.muted) if let step = statusStep(status) { HStack(spacing: 4) { ForEach(0..<5, id: \.self) { index in Capsule() .fill(index <= step ? Theme.accent : Theme.surfaceAlt) .frame(height: 4) } } .padding(.vertical, 2) } if !isTerminal(status) { Text("Updates automatically every 20 seconds.") .font(.system(size: 10)) .foregroundStyle(Theme.muted) } if needsDeposit(status) { Text(verbatim: alreadySent ? "Your \(order.depositAmount) \(order.fromTicker) deposit was sent to this address:" : "Send exactly \(order.depositAmount) \(order.fromTicker) to this address:") .font(.system(size: 12)) .foregroundStyle(Theme.muted) .padding(.top, 4) HStack(spacing: 8) { Text(order.depositAddress) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Theme.text) .lineLimit(1) .truncationMode(.middle) Button { UIPasteboard.general.string = order.depositAddress } label: { Image(systemName: "doc.on.doc") .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) Button { drawers.qr = 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) } } HStack { Text("Provider") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text(order.provider) .font(.system(size: 13)) .foregroundStyle(Theme.text) } if order.fromAmount > 0 { HStack { Text("Rate") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text("1 \(order.fromTicker) ≈ \(order.toAmount / order.fromAmount, specifier: "%.4f") \(order.toTicker)") .font(.system(size: 13)) .foregroundStyle(Theme.text) } } if let txIn = order.txIn, !txIn.isEmpty { txHashRow("Deposit tx", hash: txIn, ticker: order.fromTicker) } if let txOut = order.txOut, !txOut.isEmpty { txHashRow("Payout tx", hash: txOut, ticker: order.toTicker) } HStack { Text("Opened") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text(order.createdAt, format: Date.FormatStyle(date: .abbreviated, time: .shortened)) .font(.system(size: 12)) .foregroundStyle(Theme.text) } HStack { Text("Order ID") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text(order.id) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Theme.text) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: 160, alignment: .trailing) Button { UIPasteboard.general.string = order.id } label: { Image(systemName: "doc.on.doc") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.accent) } } if let supportUrl = order.supportUrl, let url = URL(string: supportUrl) { HStack { Text("Support") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Link("Contact \(order.provider)", destination: url) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.accent) } } } } if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 { MoneroAccountPicker(accounts: model.moneroAccounts, selection: $xmrAccountIndex, onSelect: { xmrAccountPicked = true }) } if needsDeposit(status) { if alreadySent { // The deposit was already broadcast; re-sending would double-pay. HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.success) Text("Deposit sent") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Theme.text) } .frame(maxWidth: .infinity) .padding(.vertical, 15) .background(Theme.surfaceAlt) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } else { PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) { Task { await sendDeposit(order) } } } } SecondaryButton(title: "Check status now") { Task { createdOrder = await model.refreshSwapStatus(order) } } if let orderPage = URL(string: "https://kyc.rip/swap?id=\(order.id)") { LinkButton(title: "View on kyc.rip", systemImage: "globe", url: orderPage) } Button { confirmRemoveOrder = true } label: { Text("Remove this order") .font(.system(size: 12)) .foregroundStyle(Theme.muted) .frame(maxWidth: .infinity) } if let localError { MessageBanner(message: localError, isError: true) } Spacer(minLength: 0) } .alert("Remove this swap from history?", isPresented: $confirmRemoveOrder) { Button("Remove", role: .destructive) { model.removeSwapOrder(id: order.id) createdOrder = nil } Button("Cancel", role: .cancel) {} } message: { Text("Only the local record is removed. If you already sent the deposit, \(order.provider) still has the order.") } } /// A truncated, copyable transaction hash row for the order card, with a /// link to the right block explorer when the ticker is known. private func txHashRow(_ label: String, hash: String, ticker: String) -> some View { HStack { Text(label) .font(.system(size: 12, weight: .medium)) .foregroundStyle(Theme.muted) Spacer() Text(hash) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Theme.text) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: 160, alignment: .trailing) Button { UIPasteboard.general.string = hash } label: { Image(systemName: "doc.on.doc") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.accent) } if let explorer = BlockExplorer.transactionURL(ticker: ticker, hash: hash) { Link(destination: explorer) { Image(systemName: "safari") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.accent) } } } } 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()) .contextMenu { Button(role: .destructive) { if createdOrder?.id == order.id { createdOrder = nil } model.removeSwapOrder(id: order.id) } label: { Label("Remove from history", systemImage: "trash") } } } } } // 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 routes.isEmpty { localError = "No provider offers this swap right now" estimate = nil } else { // Default to the best payout; kyc.rip's "recommended" flag is shown // but not preselected because it can be rev-share driven. selectedRouteID = routes.first?.id } } catch { localError = error.localizedDescription } } private func create() async { guard let route = selectedRoute, let estimate else { return } localError = nil // Respect the provider's deposit window so the order can't be created // with an amount it would refuse. let value = Double(amount) ?? 0 if let min = route.minDeposit, value < min { localError = "\(route.provider) needs at least \(trim(min)) \(from.symbol)" return } if let max = route.maxDeposit, value > max { localError = "\(route.provider) takes at most \(trim(max)) \(from.symbol)" return } creating = true defer { creating = false } do { createdOrder = try await model.createSwap(route: route, estimateID: estimate.id, 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, accountIndex: xmrAccountIndex) // Record that the deposit left this device so the button can never be // pressed twice for the same order. var sent = order sent.depositSent = true model.saveSwapOrder(sent) createdOrder = sent await model.refresh(force: true) createdOrder = await model.refreshSwapStatus(sent) } catch { localError = error.localizedDescription } } } struct SwapStatusBadge: View { var status: String private var colour: Color { switch status.uppercased() { case "FINISHED", "SUCCESS": return Theme.success case "WAITING", "NEW", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent case "REFUNDED": return Theme.muted case "FAILED", "EXPIRED", "OVERDUE": return Theme.danger default: return Theme.muted } } 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()) } }