From 3ff5385a9b6fd2f7ede65627ec525f137f9a346c Mon Sep 17 00:00:00 2001 From: TZGyn Date: Mon, 26 Oct 2026 21:31:41 +0800 Subject: [PATCH] Show live swap status, explorer links, and full kyc.rip route details --- apps/ios/Koins/AppModel.swift | 42 +- apps/ios/Koins/Bridge/KoinsMoneroWallet.mm | 6 +- apps/ios/Koins/Models.swift | 20 + apps/ios/Koins/Views/AccountsView.swift | 12 +- apps/ios/Koins/Views/BottomDrawer.swift | 18 +- apps/ios/Koins/Views/Components.swift | 68 +++ apps/ios/Koins/Views/DashboardViews.swift | 38 +- apps/ios/Koins/Views/RootView.swift | 72 +-- apps/ios/Koins/Views/SettingsView.swift | 8 +- apps/ios/Koins/Views/SwapView.swift | 532 ++++++++++++++++-- .../Koins/Views/TransactionDetailView.swift | 4 + apps/ios/Koins/Wallet/MoneroService.swift | 6 +- apps/ios/Koins/Wallet/SwapService.swift | 83 ++- 13 files changed, 759 insertions(+), 150 deletions(-) diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index f0de3e3..85138c5 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -109,7 +109,8 @@ final class AppModel: ObservableObject { 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 { + func createSwap(route: SwapRoute, estimateID: String, 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") } @@ -117,6 +118,7 @@ final class AppModel: ObservableObject { throw SwapError.api("No \(to.title) wallet to receive into") } let order = try await SwapService.shared.createOrder(route: route, + estimateID: estimateID, from: from.rawValue, to: to.rawValue, addressTo: destination, @@ -127,23 +129,36 @@ final class AppModel: ObservableObject { } /// Sends the swap deposit using the same payment path as a normal send. - func sendSwapDeposit(_ order: SwapOrder) async throws { + func sendSwapDeposit(_ order: SwapOrder, accountIndex: Int = 0) async throws { try await send(to: order.depositAddress, amount: String(order.depositAmount), tag: order.depositMemo ?? "", - priority: .defaultPriority) + priority: .defaultPriority, + accountIndex: accountIndex) } 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 + order.txIn = updated.txIn ?? order.txIn + order.txOut = updated.txOut ?? order.txOut + if order.txIn?.isEmpty == true { order.txIn = nil } + if order.txOut?.isEmpty == true { order.txOut = nil } + order.supportUrl = updated.supportUrl ?? order.supportUrl if let index = swapOrders.firstIndex(where: { $0.id == order.id }) { swapOrders[index] = order } + WalletStore.saveSwapOrder(order) return order } + /// Persists a local change to an order (for example, the deposit was sent). + func saveSwapOrder(_ order: SwapOrder) { + WalletStore.saveSwapOrder(order) + loadSwapOrders() + } + func removeSwapOrder(id: String) { WalletStore.removeSwapOrder(id: id) loadSwapOrders() @@ -197,7 +212,11 @@ final class AppModel: ObservableObject { if let xmrWallet { await openMonero(afterRestore: false, wallet: xmrWallet) } - if xrpWallet != nil || moneroReady { + // Restore the chain the user was last on, as long as that wallet exists. + let savedChain = UserDefaults.standard.string(forKey: "koins.chain").flatMap(Chain.init(rawValue:)) + if let savedChain, (savedChain == .xrp ? xrpWallet : xmrWallet) != nil { + chain = savedChain + } else if xrpWallet != nil || moneroReady { chain = xrpWallet != nil ? .xrp : .xmr } if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable { @@ -245,6 +264,7 @@ final class AppModel: ObservableObject { func select(_ newChain: Chain) async { chain = newChain + UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain") refreshToken = UUID() errorMessage = nil statusMessage = nil @@ -475,7 +495,8 @@ final class AppModel: ObservableObject { // MARK: - Sends - func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws { + func send(to: String, amount: String, tag: String, priority: TransactionPriority, + accountIndex: Int = 0) async throws { try await requireAuth(reason: "Authorize this payment") let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces)) if chain == .xrp { @@ -491,7 +512,10 @@ final class AppModel: ObservableObject { await refresh(force: true) return } - _ = try await MoneroService.shared.send(address: to, amountXMR: amount, priority: priority) + _ = try await MoneroService.shared.send(address: to, + amountXMR: amount, + priority: priority, + accountIndex: accountIndex) statusMessage = "Payment sent" await refresh(force: true) } @@ -539,6 +563,12 @@ final class AppModel: ObservableObject { await refresh(force: true) } + /// The account a payment should leave from by default: the first one with + /// spendable funds, otherwise the primary account. + var defaultMoneroAccountIndex: Int { + moneroAccounts.first { (Decimal(string: $0.unlocked) ?? 0) > 0 }?.index ?? 0 + } + /// Refreshes the account/subaddress tree independently of the active chain. func loadAccounts() async { guard moneroReady else { return } diff --git a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm index 21e2e58..565c00d 100644 --- a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm +++ b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm @@ -360,11 +360,11 @@ static void connectDaemon(monero_wallet_full *w) { destination->m_amount = (uint64_t)[req[@"amount"] longLongValue]; config.m_destinations = { destination }; config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue]; + config.m_account_index = (uint32_t)[req[@"accountIndex"] integerValue]; + // create_txs relays the transaction when m_relay is set, so it must + // not be submitted a second time. config.m_relay = true; auto txs = g_wallet->create_txs(config); - const auto &txSet = txs[0]->m_tx_set; - std::string signedHex = (txSet && txSet->m_signed_tx_hex) ? *txSet->m_signed_tx_hex : ""; - g_wallet->submit_txs(signedHex); resp[@"txHash"] = [NSString stringWithUTF8String:(txs[0]->m_hash ? *txs[0]->m_hash : "").c_str()]; resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue; } diff --git a/apps/ios/Koins/Models.swift b/apps/ios/Koins/Models.swift index 46572a7..14e5056 100644 --- a/apps/ios/Koins/Models.swift +++ b/apps/ios/Koins/Models.swift @@ -11,6 +11,26 @@ enum Chain: String, Codable, CaseIterable { } } +/// Web block explorers used for "view on the web" links. +enum BlockExplorer { + static func transactionURL(chain: Chain, hash: String) -> URL? { + guard !hash.isEmpty else { return nil } + switch chain { + case .xmr: return URL(string: "https://xmrchain.net/tx/\(hash)") + case .xrp: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)") + } + } + + /// Same lookup for a raw ticker ("XMR"/"XRP"), as used by the swap API. + static func transactionURL(ticker: String, hash: String) -> URL? { + switch ticker.uppercased() { + case "XMR": return transactionURL(chain: .xmr, hash: hash) + case "XRP": return transactionURL(chain: .xrp, hash: hash) + default: return nil + } + } +} + struct WalletRecord: Codable, Identifiable, Equatable { let id: String var name: String diff --git a/apps/ios/Koins/Views/AccountsView.swift b/apps/ios/Koins/Views/AccountsView.swift index 685dd4b..539b81e 100644 --- a/apps/ios/Koins/Views/AccountsView.swift +++ b/apps/ios/Koins/Views/AccountsView.swift @@ -2,11 +2,11 @@ import SwiftUI struct AccountsView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var drawers: DrawerCoordinator @Binding var route: Route @State private var error: String? @State private var busy = false - @State private var qrAddress: AddressQR? @State private var showAccountPrompt = false @State private var showSubaddressPrompt = false @State private var pendingLabel = "" @@ -37,14 +37,6 @@ struct AccountsView: View { } } .task(id: model.moneroReady) { await model.loadAccounts() } - .bottomDrawer(isPresented: Binding( - get: { qrAddress != nil }, - set: { if !$0 { qrAddress = nil } } - )) { - if let item = qrAddress { - AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } - } - } .alert("Account name", isPresented: $showAccountPrompt) { TextField("e.g. Savings", text: $pendingLabel) Button("Create") { createAccount() } @@ -97,7 +89,7 @@ struct AccountsView: View { private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View { Button { - qrAddress = AddressQR(title: title, address: address) + drawers.qr = AddressQR(title: title, address: address) } label: { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 2) { diff --git a/apps/ios/Koins/Views/BottomDrawer.swift b/apps/ios/Koins/Views/BottomDrawer.swift index 8fc355f..b7603c0 100644 --- a/apps/ios/Koins/Views/BottomDrawer.swift +++ b/apps/ios/Koins/Views/BottomDrawer.swift @@ -10,6 +10,15 @@ extension View { } } +/// One place for the drawers shown above every screen. Attaching them at the +/// root keeps them anchored to the window instead of to scrolled content. +@MainActor +final class DrawerCoordinator: ObservableObject { + @Published var transaction: TransactionRecord? + @Published var qr: AddressQR? + @Published var settings = false +} + private struct BottomDrawerHost: View { let host: Host @Binding var isPresented: Bool @@ -55,18 +64,19 @@ private struct BottomDrawerHost: View { content() .padding(.top, 14) - .padding(.bottom, bottomInset + 16) + .padding(.bottom, 16) } .frame(maxWidth: .infinity, alignment: .top) .frame(maxHeight: maxHeight) .background(Theme.surface) .clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) - .offset(y: dragOffset) + // Push the panel's background down to the physical screen edge without + // taking the host content out of its safe area. + .offset(y: dragOffset + bottomInset) .gesture(dismissDrag) .transition(.move(edge: .bottom)) } } - .ignoresSafeArea() .animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented) } @@ -88,4 +98,4 @@ private struct BottomDrawerHost: View { } } } -} \ No newline at end of file +} diff --git a/apps/ios/Koins/Views/Components.swift b/apps/ios/Koins/Views/Components.swift index f2fc25e..4592dd5 100644 --- a/apps/ios/Koins/Views/Components.swift +++ b/apps/ios/Koins/Views/Components.swift @@ -51,6 +51,26 @@ struct SecondaryButton: View { } } +/// A full-width button that opens a web page, styled like SecondaryButton. +struct LinkButton: View { + var title: String + var systemImage: String = "safari" + var url: URL + + var body: some View { + Link(destination: url) { + Label(title, systemImage: systemImage) + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Theme.surface) + .foregroundStyle(Theme.text) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } +} + struct Card: View { @ViewBuilder var content: Content @@ -146,6 +166,54 @@ struct ChainPicker: View { } } +/// Picks which Monero account a payment leaves from. Each account has its own +/// balance, so "Max" and the wallet's spend checks need the matching index. +struct MoneroAccountPicker: View { + var accounts: [MoneroAccount] + @Binding var selection: Int + var onSelect: (() -> Void)? + + private var selected: MoneroAccount? { + accounts.first(where: { $0.index == selection }) ?? accounts.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + FieldLabel(text: "Send from") + Menu { + ForEach(accounts) { account in + Button { + selection = account.index + onSelect?() + } label: { + Text("\(account.title) · \(account.balance) XMR") + } + } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(selected?.title ?? "Primary account") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.text) + Text("\(selected?.unlocked ?? "0") XMR available") + .font(.system(size: 11)) + .foregroundStyle(Theme.muted) + } + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.muted) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Theme.surface) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Theme.border)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + } +} + struct QRCodeView: View { var text: String diff --git a/apps/ios/Koins/Views/DashboardViews.swift b/apps/ios/Koins/Views/DashboardViews.swift index d215171..04b3b97 100644 --- a/apps/ios/Koins/Views/DashboardViews.swift +++ b/apps/ios/Koins/Views/DashboardViews.swift @@ -3,12 +3,12 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var drawers: DrawerCoordinator @Binding var route: Route @State private var showSeed = false @State private var revealedSeed: String? @State private var seedError: String? - @State private var selectedTransaction: TransactionRecord? var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -70,14 +70,14 @@ struct DashboardView: View { Text("Activity") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.muted) - ForEach(model.transactions) { record in + ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in Button { - selectedTransaction = record + drawers.transaction = record } label: { TransactionRow(record: record, symbol: model.symbol) } .buttonStyle(.plain) - if record.id != model.transactions.last?.id { + if index < model.transactions.count - 1 { Divider().overlay(Theme.border) } } @@ -108,16 +108,6 @@ struct DashboardView: View { MessageBanner(message: seedError, isError: true) } } - .bottomDrawer(isPresented: Binding( - get: { selectedTransaction != nil }, - set: { if !$0 { selectedTransaction = nil } } - )) { - if let record = selectedTransaction { - TransactionDetailView(record: record, - symbol: model.symbol, - chain: model.chain) { selectedTransaction = nil } - } - } .sheet(isPresented: $showSeed) { if let revealedSeed { SeedRevealView(seed: revealedSeed, symbol: model.symbol) { showSeed = false } @@ -134,6 +124,8 @@ struct SendView: View { @State private var amount = "" @State private var tag = "" @State private var priority: TransactionPriority = .defaultPriority + @State private var accountIndex = 0 + @State private var accountPicked = false @State private var localError: String? @State private var status: String? @State private var showScanner = false @@ -159,6 +151,12 @@ struct SendView: View { } } + if model.chain == .xmr, model.moneroAccounts.count > 1 { + MoneroAccountPicker(accounts: model.moneroAccounts, + selection: $accountIndex, + onSelect: { accountPicked = true }) + } + VStack(alignment: .leading, spacing: 6) { FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address") KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination) @@ -203,7 +201,11 @@ struct SendView: View { localError = nil model.clearMessages() do { - try await model.send(to: destination, amount: amount, tag: tag, priority: priority) + try await model.send(to: destination, + amount: amount, + tag: tag, + priority: priority, + accountIndex: accountIndex) amount = "" tag = "" route = .dashboard @@ -239,6 +241,12 @@ struct SendView: View { photoItem = nil } } + .onAppear { + if !accountPicked { accountIndex = model.defaultMoneroAccountIndex } + } + .onChange(of: model.moneroAccounts.count) { _ in + if !accountPicked { accountIndex = model.defaultMoneroAccountIndex } + } } /// Fills the form from a scanned code, accepting both plain addresses and diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 2e19b63..b712af1 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -31,6 +31,7 @@ struct RootView: View { return .home } @State private var seedBox: SeedBox? + @StateObject private var drawers = DrawerCoordinator() var body: some View { ZStack { @@ -63,6 +64,33 @@ struct RootView: View { .scrollDismissesKeyboard(.interactively) } .preferredColorScheme(.dark) + .task { await model.bootstrap() } + .sheet(item: $seedBox) { box in + SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil } + } + .bottomDrawer(isPresented: Binding( + get: { drawers.transaction != nil }, + set: { if !$0 { drawers.transaction = nil } } + )) { + if let record = drawers.transaction { + TransactionDetailView(record: record, + symbol: model.symbol, + chain: model.chain) { drawers.transaction = nil } + } + } + .bottomDrawer(isPresented: Binding( + get: { drawers.qr != nil }, + set: { if !$0 { drawers.qr = nil } } + )) { + if let item = drawers.qr { + AddressQRSheet(title: item.title, address: item.address) { drawers.qr = nil } + } + } + .bottomDrawer(isPresented: $drawers.settings) { + SettingsView() + } + .environmentObject(drawers) + // Above the drawers so a lock always hides whatever was on screen. .overlay { if model.locked, model.faceIDEnabled { LockScreen { @@ -70,10 +98,6 @@ struct RootView: View { } } } - .task { await model.bootstrap() } - .sheet(item: $seedBox) { box in - SeedRevealView(seed: box.seed, symbol: box.symbol) { seedBox = nil } - } .onChange(of: model.chain) { _ in if route == .dashboard { route = .home } } @@ -90,10 +114,8 @@ struct RootView: View { struct HomeView: View { @EnvironmentObject private var model: AppModel + @EnvironmentObject private var drawers: DrawerCoordinator @Binding var route: Route - @State private var selectedTransaction: TransactionRecord? - @State private var showSettings = false - @State private var qrAddress: AddressQR? var body: some View { VStack(spacing: 18) { @@ -114,28 +136,6 @@ struct HomeView: View { MessageBanner(message: message, isError: false) } } - .bottomDrawer(isPresented: Binding( - get: { selectedTransaction != nil }, - set: { if !$0 { selectedTransaction = nil } } - )) { - if let record = selectedTransaction { - TransactionDetailView(record: record, - symbol: model.symbol, - chain: model.chain) { selectedTransaction = nil } - } - } - .bottomDrawer(isPresented: Binding( - get: { qrAddress != nil }, - set: { if !$0 { qrAddress = nil } } - )) { - if let item = qrAddress { - AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil } - } - } - .bottomDrawer(isPresented: $showSettings) { - SettingsView() - .environmentObject(model) - } } private var header: some View { @@ -150,7 +150,7 @@ struct HomeView: View { } Spacer() Button { - showSettings = true + drawers.settings = true } label: { Image(systemName: "gearshape") .font(.system(size: 17, weight: .medium)) @@ -209,7 +209,7 @@ struct HomeView: View { // the front card away to bring the next one up. AccountStack(accounts: model.moneroAccounts, fiat: { model.fiatValue(of: $0, chain: .xmr) }, - onQR: { qrAddress = AddressQR(title: $0.title, address: $0.address) }) + onQR: { drawers.qr = AddressQR(title: $0.title, address: $0.address) }) } if model.chain == .xmr { @@ -257,14 +257,16 @@ struct HomeView: View { Text("Activity") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.muted) - ForEach(model.transactions) { record in + // A Monero transaction can produce several transfer entries with the + // same hash, so index-based identities keep the rows stable. + ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in Button { - selectedTransaction = record + drawers.transaction = record } label: { TransactionRow(record: record, symbol: model.symbol) } .buttonStyle(.plain) - if record.id != model.transactions.last?.id { + if index < model.transactions.count - 1 { Divider().overlay(Theme.border) } } @@ -281,8 +283,6 @@ struct HomeView: View { private var moneroUnavailableCard: some View { VStack(spacing: 14) { - ChainPicker(selection: Binding(get: { model.chain }, - set: { newValue in Task { await model.select(newValue) } })) Card { VStack(alignment: .leading, spacing: 10) { Text("Monero wallet") diff --git a/apps/ios/Koins/Views/SettingsView.swift b/apps/ios/Koins/Views/SettingsView.swift index f9c3df4..9c19491 100644 --- a/apps/ios/Koins/Views/SettingsView.swift +++ b/apps/ios/Koins/Views/SettingsView.swift @@ -2,7 +2,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject private var model: AppModel - @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var drawers: DrawerCoordinator @State private var showDeleteConfirm = false private let currencies = ["USD", "MYR", "JPY"] @@ -10,7 +10,9 @@ struct SettingsView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { - ScreenHeader(title: "Settings", subtitle: "Security and device options", onBack: { dismiss() }) + ScreenHeader(title: "Settings", + subtitle: "Security and device options", + onBack: { drawers.settings = false }) Card { VStack(alignment: .leading, spacing: 10) { @@ -108,7 +110,7 @@ struct SettingsView: View { Button("Delete wallet", role: .destructive) { Task { await model.deleteWallet() - dismiss() + drawers.settings = false } } Button("Cancel", role: .cancel) {} diff --git a/apps/ios/Koins/Views/SwapView.swift b/apps/ios/Koins/Views/SwapView.swift index d707097..cd4ddd2 100644 --- a/apps/ios/Koins/Views/SwapView.swift +++ b/apps/ios/Koins/Views/SwapView.swift @@ -1,23 +1,37 @@ 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 qrAddress: AddressQR? + @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) } @@ -25,10 +39,127 @@ struct SwapView: View { private var fromBalanceText: String { let value = Decimal(string: fromBalance.total) ?? 0 let formatter = NumberFormatter() - formatter.maximumFractionDigits = 6 + 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", @@ -47,15 +178,14 @@ struct SwapView: View { } .task { from = model.chain + if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex } 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 } - } + .task(id: createdOrder?.id) { + await monitorOrder() + } + .onChange(of: model.moneroAccounts.count) { _ in + if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex } } } @@ -102,6 +232,12 @@ struct SwapView: View { .accessibilityLabel("Swap direction") } + if from == .xmr, model.moneroAccounts.count > 1 { + MoneroAccountPicker(accounts: model.moneroAccounts, + selection: $xmrAccountIndex, + onSelect: { xmrAccountPicked = true }) + } + Divider().overlay(Theme.border) HStack { @@ -116,15 +252,27 @@ struct SwapView: View { if let estimate { VStack(alignment: .leading, spacing: 4) { - Text("≈ \(String(format: "%.6f", estimate.amountTo)) \(to.symbol)") + Text("≈ \(String(format: "%.6f", selectedRoute?.amountTo ?? 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" } ?? "")") + 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)) @@ -155,56 +303,145 @@ struct SwapView: View { } } + 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 { - VStack(alignment: .leading, spacing: 14) { + 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("Swap deposit") + Text(statusTitle(status)) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.text) Spacer() - SwapStatusBadge(status: order.status ?? "WAITING") + SwapStatusBadge(status: status) } - Text("Send exactly \(order.depositAmount, specifier: "%.6g") \(order.fromTicker) to this address:") + Text(alreadySent && needsDeposit(status) + ? "Deposit sent — waiting for \(order.provider) to see it." + : statusDetail(status, order: order)) .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)) + 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) + } } - .disabled(order.depositAddress.isEmpty) + .padding(.vertical, 2) } - .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)) + 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) + } } } @@ -229,15 +466,122 @@ struct SwapView: View { .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) + } + } } } - PrimaryButton(title: sendingDeposit ? "Sending…" : "Send deposit", disabled: sendingDeposit) { - Task { await sendDeposit(order) } + if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 { + MoneroAccountPicker(accounts: model.moneroAccounts, + selection: $xmrAccountIndex, + onSelect: { xmrAccountPicked = true }) } - SecondaryButton(title: "Refresh status") { - Task { _ = await model.refreshSwapStatus(order) } + 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 { @@ -246,6 +590,46 @@ struct SwapView: View { 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 { @@ -276,6 +660,14 @@ struct SwapView: View { } .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") + } + } } } } @@ -288,9 +680,13 @@ struct SwapView: View { defer { estimating = false } do { estimate = try await model.estimateSwap(amount: amount, from: from, to: to) - if estimate?.routes.isEmpty == true { + 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 @@ -298,12 +694,27 @@ struct SwapView: View { } private func create() async { - guard let route = estimate?.routes.first else { return } + 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, amountFrom: amount, from: from, to: to) + createdOrder = try await model.createSwap(route: route, + estimateID: estimate.id, + amountFrom: amount, + from: from, + to: to) } catch { localError = error.localizedDescription } @@ -314,8 +725,15 @@ struct SwapView: View { sendingDeposit = true defer { sendingDeposit = false } do { - try await model.sendSwapDeposit(order) + 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 } @@ -326,10 +744,12 @@ 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 + 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 } } diff --git a/apps/ios/Koins/Views/TransactionDetailView.swift b/apps/ios/Koins/Views/TransactionDetailView.swift index 72b7a2d..3735f72 100644 --- a/apps/ios/Koins/Views/TransactionDetailView.swift +++ b/apps/ios/Koins/Views/TransactionDetailView.swift @@ -109,6 +109,10 @@ struct TransactionDetailView: View { } } + if let explorer = BlockExplorer.transactionURL(chain: chain, hash: record.id) { + LinkButton(title: "View on explorer", url: explorer) + } + PrimaryButton(title: "Done", action: onDone) } .padding(20) diff --git a/apps/ios/Koins/Wallet/MoneroService.swift b/apps/ios/Koins/Wallet/MoneroService.swift index 834ee1d..b5240a1 100644 --- a/apps/ios/Koins/Wallet/MoneroService.swift +++ b/apps/ios/Koins/Wallet/MoneroService.swift @@ -313,12 +313,14 @@ final class MoneroService { // MARK: - Sends - func send(address: String, amountXMR: String, priority: TransactionPriority) async throws -> (hash: String, fee: String) { + func send(address: String, amountXMR: String, priority: TransactionPriority, + accountIndex: Int = 0) async throws -> (hash: String, fee: String) { let atomic = try MoneroAmount.atomic(fromXMR: amountXMR) let response = try await invoke(["op": "transferTo", "address": address.trimmingCharacters(in: .whitespacesAndNewlines), "amount": String(atomic), - "priority": priority.rawValue]) + "priority": priority.rawValue, + "accountIndex": accountIndex]) return (response["txHash"] as? String ?? "", MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0")) } diff --git a/apps/ios/Koins/Wallet/SwapService.swift b/apps/ios/Koins/Wallet/SwapService.swift index f5cf655..4722bf7 100644 --- a/apps/ios/Koins/Wallet/SwapService.swift +++ b/apps/ios/Koins/Wallet/SwapService.swift @@ -2,17 +2,24 @@ import Foundation /// A route offered by one of kyc.rip's underlying exchange engines. struct SwapRoute: Identifiable { - var id: String { provider + engine } + var id: String { "\(provider).\(engine).\(amountTo)" } var provider: String var engine: String var amountFrom: Double var amountTo: Double var eta: Int? // minutes var fixedRate: Bool - var spread: Double? + var spread: Double? // percent above/below the market rate + var kycRating: String? // A/B/C privacy grade from the aggregator + var logPolicy: String? // the provider's logging policy grade + var insurance: Int? // percent of the swap insured by the provider + var minDeposit: Double? + var maxDeposit: Double? + var recommended: Bool } struct SwapEstimate { + var id: String var amountTo: Double var routes: [SwapRoute] } @@ -33,8 +40,27 @@ struct SwapOrder: Codable, Identifiable { var createdAt: Date var eta: Int? - /// Latest-known lifecycle state (not persisted). + /// Latest-known lifecycle state (refreshed from the API). var status: String? + + /// Transaction hashes reported by the provider once the swap progresses. + var txIn: String? + var txOut: String? + + /// Provider support link, reported by the status endpoint when available. + var supportUrl: String? + + /// Set once the deposit has been broadcast from this device, so the + /// "Send deposit" button can never be pressed twice for the same order. + var depositSent: Bool? +} + +/// Latest-known state of an order, as reported by the status endpoint. +struct SwapOrderUpdate { + var status: String + var txIn: String? + var txOut: String? + var supportUrl: String? } enum SwapError: LocalizedError { @@ -94,14 +120,21 @@ final class SwapService { 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) + spread: (entry["spread"] as? NSNumber)?.doubleValue, + kycRating: entry["kyc"] as? String, + logPolicy: entry["log_policy"] as? String, + insurance: (entry["insurance"] as? NSNumber)?.intValue, + minDeposit: (entry["min_deposit"] as? NSNumber)?.doubleValue, + maxDeposit: (entry["max_deposit"] as? NSNumber)?.doubleValue, + recommended: (entry["recommended"] as? Bool) ?? false) } - return SwapEstimate(amountTo: amountTo, routes: routes) + return SwapEstimate(id: json["id"] as? String ?? "", amountTo: amountTo, routes: routes) } // MARK: - Orders func createOrder(route: SwapRoute, + estimateID: String, from: String, to: String, addressTo: String, @@ -111,7 +144,7 @@ final class SwapService { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let body: [String: Any] = [ + var body: [String: Any] = [ "provider": route.provider, "engine": route.engine, "from_currency": from, @@ -120,7 +153,15 @@ final class SwapService { "to_network": "Mainnet", "address_to": addressTo, "amount_from": amountFrom, + "amount_to": route.amountTo, + "fixed_rate": route.fixedRate, + "source": "swap", + "isPayment": false, ] + // The create endpoint expects the quote it was estimated against. + if !estimateID.isEmpty { body["trade_id"] = estimateID } + if let kyc = route.kycRating { body["kyc_rating"] = kyc } + if let log = route.logPolicy { body["log_policy"] = log } request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) @@ -141,16 +182,18 @@ final class SwapService { depositAddress: json["depositAddress"] as? String ?? "", depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom, depositMemo: json["depositMemo"] as? String, - fromTicker: json["fromTicker"] as? String ?? from.uppercased(), + fromTicker: (json["fromTicker"] as? String ?? from).uppercased(), fromAmount: (json["fromAmount"] as? NSNumber)?.doubleValue ?? amountFrom, - toTicker: json["toTicker"] as? String ?? to.uppercased(), + 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) + eta: (json["eta"] as? NSNumber)?.intValue, + status: json["status"] as? String, + supportUrl: nonEmpty(json["supportUrl"] as? String)) } - func orderStatus(_ order: SwapOrder) async throws -> (status: String, txIn: String?, txOut: String?) { + func orderStatus(_ order: SwapOrder) async throws -> SwapOrderUpdate { 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 } @@ -163,12 +206,22 @@ final class SwapService { } 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 } + let txIn = nonEmpty(json["txIn"] as? String) + ?? (json["txIn"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) } + let txOut = nonEmpty(json["txOut"] as? String) + ?? (json["txOut"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) } - return (json["status"] as? String ?? "UNKNOWN", txIn, txOut) + return SwapOrderUpdate(status: json["status"] as? String ?? "UNKNOWN", + txIn: txIn, + txOut: txOut, + supportUrl: nonEmpty(json["supportUrl"] as? String)) + } + + /// The API sends blank strings while a hash is still unknown; treat those as + /// absent so the UI does not render empty rows. + private func nonEmpty(_ value: String?) -> String? { + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return value } private func check(_ response: URLResponse) throws {