Swap XMR and XRP through kyc.rip without leaving the app
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user