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
+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())
}
}