Show live swap status, explorer links, and full kyc.rip route details
This commit is contained in:
@@ -109,7 +109,8 @@ final class AppModel: ObservableObject {
|
|||||||
return try await SwapService.shared.estimate(from: from.rawValue, to: to.rawValue, amount: amountNumber)
|
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 {
|
guard let amountNumber = Double(amountFrom.trimmingCharacters(in: .whitespaces)), amountNumber > 0 else {
|
||||||
throw SwapError.api("Enter a valid amount")
|
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")
|
throw SwapError.api("No \(to.title) wallet to receive into")
|
||||||
}
|
}
|
||||||
let order = try await SwapService.shared.createOrder(route: route,
|
let order = try await SwapService.shared.createOrder(route: route,
|
||||||
|
estimateID: estimateID,
|
||||||
from: from.rawValue,
|
from: from.rawValue,
|
||||||
to: to.rawValue,
|
to: to.rawValue,
|
||||||
addressTo: destination,
|
addressTo: destination,
|
||||||
@@ -127,23 +129,36 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends the swap deposit using the same payment path as a normal send.
|
/// 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,
|
try await send(to: order.depositAddress,
|
||||||
amount: String(order.depositAmount),
|
amount: String(order.depositAmount),
|
||||||
tag: order.depositMemo ?? "",
|
tag: order.depositMemo ?? "",
|
||||||
priority: .defaultPriority)
|
priority: .defaultPriority,
|
||||||
|
accountIndex: accountIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
|
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
|
||||||
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
|
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
|
||||||
var order = order
|
var order = order
|
||||||
order.status = updated.status
|
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 }) {
|
if let index = swapOrders.firstIndex(where: { $0.id == order.id }) {
|
||||||
swapOrders[index] = order
|
swapOrders[index] = order
|
||||||
}
|
}
|
||||||
|
WalletStore.saveSwapOrder(order)
|
||||||
return 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) {
|
func removeSwapOrder(id: String) {
|
||||||
WalletStore.removeSwapOrder(id: id)
|
WalletStore.removeSwapOrder(id: id)
|
||||||
loadSwapOrders()
|
loadSwapOrders()
|
||||||
@@ -197,7 +212,11 @@ final class AppModel: ObservableObject {
|
|||||||
if let xmrWallet {
|
if let xmrWallet {
|
||||||
await openMonero(afterRestore: false, wallet: 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
|
chain = xrpWallet != nil ? .xrp : .xmr
|
||||||
}
|
}
|
||||||
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
||||||
@@ -245,6 +264,7 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
func select(_ newChain: Chain) async {
|
func select(_ newChain: Chain) async {
|
||||||
chain = newChain
|
chain = newChain
|
||||||
|
UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain")
|
||||||
refreshToken = UUID()
|
refreshToken = UUID()
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
statusMessage = nil
|
statusMessage = nil
|
||||||
@@ -475,7 +495,8 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Sends
|
// 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")
|
try await requireAuth(reason: "Authorize this payment")
|
||||||
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
|
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
|
||||||
if chain == .xrp {
|
if chain == .xrp {
|
||||||
@@ -491,7 +512,10 @@ final class AppModel: ObservableObject {
|
|||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
return
|
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"
|
statusMessage = "Payment sent"
|
||||||
await refresh(force: true)
|
await refresh(force: true)
|
||||||
}
|
}
|
||||||
@@ -539,6 +563,12 @@ final class AppModel: ObservableObject {
|
|||||||
await refresh(force: true)
|
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.
|
/// Refreshes the account/subaddress tree independently of the active chain.
|
||||||
func loadAccounts() async {
|
func loadAccounts() async {
|
||||||
guard moneroReady else { return }
|
guard moneroReady else { return }
|
||||||
|
|||||||
@@ -360,11 +360,11 @@ static void connectDaemon(monero_wallet_full *w) {
|
|||||||
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
|
destination->m_amount = (uint64_t)[req[@"amount"] longLongValue];
|
||||||
config.m_destinations = { destination };
|
config.m_destinations = { destination };
|
||||||
config.m_priority = (monero_tx_priority)[req[@"priority"] integerValue];
|
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;
|
config.m_relay = true;
|
||||||
auto txs = g_wallet->create_txs(config);
|
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[@"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;
|
resp[@"fee"] = @((txs[0]->m_fee ? *txs[0]->m_fee : 0)).stringValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
struct WalletRecord: Codable, Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
var name: String
|
var name: String
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import SwiftUI
|
|||||||
|
|
||||||
struct AccountsView: View {
|
struct AccountsView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
|
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var qrAddress: AddressQR?
|
|
||||||
@State private var showAccountPrompt = false
|
@State private var showAccountPrompt = false
|
||||||
@State private var showSubaddressPrompt = false
|
@State private var showSubaddressPrompt = false
|
||||||
@State private var pendingLabel = ""
|
@State private var pendingLabel = ""
|
||||||
@@ -37,14 +37,6 @@ struct AccountsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: model.moneroReady) { await model.loadAccounts() }
|
.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) {
|
.alert("Account name", isPresented: $showAccountPrompt) {
|
||||||
TextField("e.g. Savings", text: $pendingLabel)
|
TextField("e.g. Savings", text: $pendingLabel)
|
||||||
Button("Create") { createAccount() }
|
Button("Create") { createAccount() }
|
||||||
@@ -97,7 +89,7 @@ struct AccountsView: View {
|
|||||||
|
|
||||||
private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
|
private func addressRow(title: String, address: String, isUsed: Bool, balance: String? = nil) -> some View {
|
||||||
Button {
|
Button {
|
||||||
qrAddress = AddressQR(title: title, address: address)
|
drawers.qr = AddressQR(title: title, address: address)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
|||||||
@@ -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<Host: View, Content: View>: View {
|
private struct BottomDrawerHost<Host: View, Content: View>: View {
|
||||||
let host: Host
|
let host: Host
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@@ -55,18 +64,19 @@ private struct BottomDrawerHost<Host: View, Content: View>: View {
|
|||||||
|
|
||||||
content()
|
content()
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
.padding(.bottom, bottomInset + 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
.frame(maxHeight: maxHeight)
|
.frame(maxHeight: maxHeight)
|
||||||
.background(Theme.surface)
|
.background(Theme.surface)
|
||||||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous))
|
.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)
|
.gesture(dismissDrag)
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
|
||||||
.animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented)
|
.animation(.spring(response: 0.32, dampingFraction: 0.86), value: isPresented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Content: View>: View {
|
struct Card<Content: View>: View {
|
||||||
@ViewBuilder var content: Content
|
@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 {
|
struct QRCodeView: View {
|
||||||
var text: String
|
var text: String
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import SwiftUI
|
|||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
|
|
||||||
@State private var showSeed = false
|
@State private var showSeed = false
|
||||||
@State private var revealedSeed: String?
|
@State private var revealedSeed: String?
|
||||||
@State private var seedError: String?
|
@State private var seedError: String?
|
||||||
@State private var selectedTransaction: TransactionRecord?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
@@ -70,14 +70,14 @@ struct DashboardView: View {
|
|||||||
Text("Activity")
|
Text("Activity")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundStyle(Theme.muted)
|
.foregroundStyle(Theme.muted)
|
||||||
ForEach(model.transactions) { record in
|
ForEach(Array(model.transactions.enumerated()), id: \.offset) { index, record in
|
||||||
Button {
|
Button {
|
||||||
selectedTransaction = record
|
drawers.transaction = record
|
||||||
} label: {
|
} label: {
|
||||||
TransactionRow(record: record, symbol: model.symbol)
|
TransactionRow(record: record, symbol: model.symbol)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
if record.id != model.transactions.last?.id {
|
if index < model.transactions.count - 1 {
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,16 +108,6 @@ struct DashboardView: View {
|
|||||||
MessageBanner(message: seedError, isError: true)
|
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) {
|
.sheet(isPresented: $showSeed) {
|
||||||
if let revealedSeed {
|
if let revealedSeed {
|
||||||
SeedRevealView(seed: revealedSeed, symbol: model.symbol) { showSeed = false }
|
SeedRevealView(seed: revealedSeed, symbol: model.symbol) { showSeed = false }
|
||||||
@@ -134,6 +124,8 @@ struct SendView: View {
|
|||||||
@State private var amount = ""
|
@State private var amount = ""
|
||||||
@State private var tag = ""
|
@State private var tag = ""
|
||||||
@State private var priority: TransactionPriority = .defaultPriority
|
@State private var priority: TransactionPriority = .defaultPriority
|
||||||
|
@State private var accountIndex = 0
|
||||||
|
@State private var accountPicked = false
|
||||||
@State private var localError: String?
|
@State private var localError: String?
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var showScanner = false
|
@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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
FieldLabel(text: model.chain == .xrp ? "Destination address" : "Recipient address")
|
||||||
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
KoinsTextField(placeholder: model.chain == .xrp ? "r…" : "4…", text: $destination)
|
||||||
@@ -203,7 +201,11 @@ struct SendView: View {
|
|||||||
localError = nil
|
localError = nil
|
||||||
model.clearMessages()
|
model.clearMessages()
|
||||||
do {
|
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 = ""
|
amount = ""
|
||||||
tag = ""
|
tag = ""
|
||||||
route = .dashboard
|
route = .dashboard
|
||||||
@@ -239,6 +241,12 @@ struct SendView: View {
|
|||||||
photoItem = nil
|
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
|
/// Fills the form from a scanned code, accepting both plain addresses and
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct RootView: View {
|
|||||||
return .home
|
return .home
|
||||||
}
|
}
|
||||||
@State private var seedBox: SeedBox?
|
@State private var seedBox: SeedBox?
|
||||||
|
@StateObject private var drawers = DrawerCoordinator()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -63,6 +64,33 @@ struct RootView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.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 {
|
.overlay {
|
||||||
if model.locked, model.faceIDEnabled {
|
if model.locked, model.faceIDEnabled {
|
||||||
LockScreen {
|
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
|
.onChange(of: model.chain) { _ in
|
||||||
if route == .dashboard { route = .home }
|
if route == .dashboard { route = .home }
|
||||||
}
|
}
|
||||||
@@ -90,10 +114,8 @@ struct RootView: View {
|
|||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
@State private var selectedTransaction: TransactionRecord?
|
|
||||||
@State private var showSettings = false
|
|
||||||
@State private var qrAddress: AddressQR?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
@@ -114,28 +136,6 @@ struct HomeView: View {
|
|||||||
MessageBanner(message: message, isError: false)
|
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 {
|
private var header: some View {
|
||||||
@@ -150,7 +150,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
showSettings = true
|
drawers.settings = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
@@ -209,7 +209,7 @@ struct HomeView: View {
|
|||||||
// the front card away to bring the next one up.
|
// the front card away to bring the next one up.
|
||||||
AccountStack(accounts: model.moneroAccounts,
|
AccountStack(accounts: model.moneroAccounts,
|
||||||
fiat: { model.fiatValue(of: $0, chain: .xmr) },
|
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 {
|
if model.chain == .xmr {
|
||||||
@@ -257,14 +257,16 @@ struct HomeView: View {
|
|||||||
Text("Activity")
|
Text("Activity")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundStyle(Theme.muted)
|
.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 {
|
Button {
|
||||||
selectedTransaction = record
|
drawers.transaction = record
|
||||||
} label: {
|
} label: {
|
||||||
TransactionRow(record: record, symbol: model.symbol)
|
TransactionRow(record: record, symbol: model.symbol)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
if record.id != model.transactions.last?.id {
|
if index < model.transactions.count - 1 {
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,8 +283,6 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private var moneroUnavailableCard: some View {
|
private var moneroUnavailableCard: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ChainPicker(selection: Binding(get: { model.chain },
|
|
||||||
set: { newValue in Task { await model.select(newValue) } }))
|
|
||||||
Card {
|
Card {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Monero wallet")
|
Text("Monero wallet")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
|
|
||||||
private let currencies = ["USD", "MYR", "JPY"]
|
private let currencies = ["USD", "MYR", "JPY"]
|
||||||
@@ -10,7 +10,9 @@ struct SettingsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
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 {
|
Card {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -108,7 +110,7 @@ struct SettingsView: View {
|
|||||||
Button("Delete wallet", role: .destructive) {
|
Button("Delete wallet", role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
await model.deleteWallet()
|
await model.deleteWallet()
|
||||||
dismiss()
|
drawers.settings = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
|
/// Swap between the two wallets via kyc.rip, the public no-KYC aggregator.
|
||||||
struct SwapView: View {
|
struct SwapView: View {
|
||||||
@EnvironmentObject private var model: AppModel
|
@EnvironmentObject private var model: AppModel
|
||||||
|
@EnvironmentObject private var drawers: DrawerCoordinator
|
||||||
@Binding var route: Route
|
@Binding var route: Route
|
||||||
|
|
||||||
@State private var from: Chain = .xrp
|
@State private var from: Chain = .xrp
|
||||||
@State private var amount = ""
|
@State private var amount = ""
|
||||||
@State private var estimate: SwapEstimate?
|
@State private var estimate: SwapEstimate?
|
||||||
|
@State private var selectedRouteID: String?
|
||||||
@State private var estimating = false
|
@State private var estimating = false
|
||||||
@State private var creating = false
|
@State private var creating = false
|
||||||
@State private var sendingDeposit = false
|
@State private var sendingDeposit = false
|
||||||
@State private var createdOrder: SwapOrder?
|
@State private var createdOrder: SwapOrder?
|
||||||
@State private var localError: String?
|
@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 to: Chain { from == .xrp ? .xmr : .xrp }
|
||||||
|
|
||||||
|
private var selectedXmrAccount: MoneroAccount? {
|
||||||
|
model.moneroAccounts.first(where: { $0.index == xmrAccountIndex }) ?? model.moneroAccounts.first
|
||||||
|
}
|
||||||
|
|
||||||
private var fromBalance: Balance {
|
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 }
|
if from == model.chain { return model.balance }
|
||||||
return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false)
|
return WalletStore.cachedState(from)?.balance ?? Balance(total: "0", unlocked: "", funded: false)
|
||||||
}
|
}
|
||||||
@@ -25,10 +39,127 @@ struct SwapView: View {
|
|||||||
private var fromBalanceText: String {
|
private var fromBalanceText: String {
|
||||||
let value = Decimal(string: fromBalance.total) ?? 0
|
let value = Decimal(string: fromBalance.total) ?? 0
|
||||||
let formatter = NumberFormatter()
|
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"
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
ScreenHeader(title: "Swap",
|
ScreenHeader(title: "Swap",
|
||||||
@@ -47,15 +178,14 @@ struct SwapView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
from = model.chain
|
from = model.chain
|
||||||
|
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
|
||||||
model.loadSwapOrders()
|
model.loadSwapOrders()
|
||||||
}
|
}
|
||||||
.bottomDrawer(isPresented: Binding(
|
.task(id: createdOrder?.id) {
|
||||||
get: { qrAddress != nil },
|
await monitorOrder()
|
||||||
set: { if !$0 { qrAddress = nil } }
|
}
|
||||||
)) {
|
.onChange(of: model.moneroAccounts.count) { _ in
|
||||||
if let item = qrAddress {
|
if !xmrAccountPicked { xmrAccountIndex = model.defaultMoneroAccountIndex }
|
||||||
AddressQRSheet(title: item.title, address: item.address) { qrAddress = nil }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +232,12 @@ struct SwapView: View {
|
|||||||
.accessibilityLabel("Swap direction")
|
.accessibilityLabel("Swap direction")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if from == .xmr, model.moneroAccounts.count > 1 {
|
||||||
|
MoneroAccountPicker(accounts: model.moneroAccounts,
|
||||||
|
selection: $xmrAccountIndex,
|
||||||
|
onSelect: { xmrAccountPicked = true })
|
||||||
|
}
|
||||||
|
|
||||||
Divider().overlay(Theme.border)
|
Divider().overlay(Theme.border)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
@@ -116,15 +252,27 @@ struct SwapView: View {
|
|||||||
|
|
||||||
if let estimate {
|
if let estimate {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
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))
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(Theme.text)
|
.foregroundStyle(Theme.text)
|
||||||
if let route = estimate.routes.first {
|
if let route = selectedRoute {
|
||||||
Text("via \(route.provider)\(route.eta.map { " · ~\($0) min" } ?? "")")
|
Text(routeFacts(route))
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Theme.muted)
|
.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 {
|
} else {
|
||||||
Text("Enter an amount and get a quote")
|
Text("Enter an amount and get a quote")
|
||||||
.font(.system(size: 12))
|
.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
|
// MARK: - The order
|
||||||
|
|
||||||
private func orderCard(_ order: SwapOrder) -> some View {
|
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 {
|
Card {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Swap deposit")
|
Text(statusTitle(status))
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(Theme.text)
|
.foregroundStyle(Theme.text)
|
||||||
Spacer()
|
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))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(Theme.muted)
|
.foregroundStyle(Theme.muted)
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
if let step = statusStep(status) {
|
||||||
Text(order.depositAddress)
|
HStack(spacing: 4) {
|
||||||
.font(.system(size: 11, design: .monospaced))
|
ForEach(0..<5, id: \.self) { index in
|
||||||
.foregroundStyle(Theme.text)
|
Capsule()
|
||||||
.lineLimit(1)
|
.fill(index <= step ? Theme.accent : Theme.surfaceAlt)
|
||||||
.truncationMode(.middle)
|
.frame(height: 4)
|
||||||
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(.vertical, 2)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Theme.surfaceAlt)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
||||||
|
|
||||||
if let memo = order.depositMemo, !memo.isEmpty {
|
if !isTerminal(status) {
|
||||||
HStack {
|
Text("Updates automatically every 20 seconds.")
|
||||||
Text("Tag")
|
.font(.system(size: 10))
|
||||||
.font(.system(size: 12, weight: .medium))
|
.foregroundStyle(Theme.muted)
|
||||||
.foregroundStyle(Theme.muted)
|
}
|
||||||
Spacer()
|
|
||||||
Text(memo)
|
if needsDeposit(status) {
|
||||||
.font(.system(size: 13, design: .monospaced))
|
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)
|
.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)
|
.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) {
|
if canSend, order.fromTicker.uppercased() == "XMR", model.moneroAccounts.count > 1 {
|
||||||
Task { await sendDeposit(order) }
|
MoneroAccountPicker(accounts: model.moneroAccounts,
|
||||||
|
selection: $xmrAccountIndex,
|
||||||
|
onSelect: { xmrAccountPicked = true })
|
||||||
}
|
}
|
||||||
|
|
||||||
SecondaryButton(title: "Refresh status") {
|
if needsDeposit(status) {
|
||||||
Task { _ = await model.refreshSwapStatus(order) }
|
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 {
|
if let localError {
|
||||||
@@ -246,6 +590,46 @@ struct SwapView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
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 {
|
private var recentSwaps: some View {
|
||||||
@@ -276,6 +660,14 @@ struct SwapView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.contentShape(Rectangle())
|
.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 }
|
defer { estimating = false }
|
||||||
do {
|
do {
|
||||||
estimate = try await model.estimateSwap(amount: amount, from: from, to: to)
|
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"
|
localError = "No provider offers this swap right now"
|
||||||
estimate = nil
|
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 {
|
} catch {
|
||||||
localError = error.localizedDescription
|
localError = error.localizedDescription
|
||||||
@@ -298,12 +694,27 @@ struct SwapView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
guard let route = estimate?.routes.first else { return }
|
guard let route = selectedRoute, let estimate else { return }
|
||||||
localError = nil
|
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
|
creating = true
|
||||||
defer { creating = false }
|
defer { creating = false }
|
||||||
do {
|
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 {
|
} catch {
|
||||||
localError = error.localizedDescription
|
localError = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -314,8 +725,15 @@ struct SwapView: View {
|
|||||||
sendingDeposit = true
|
sendingDeposit = true
|
||||||
defer { sendingDeposit = false }
|
defer { sendingDeposit = false }
|
||||||
do {
|
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)
|
await model.refresh(force: true)
|
||||||
|
createdOrder = await model.refreshSwapStatus(sent)
|
||||||
} catch {
|
} catch {
|
||||||
localError = error.localizedDescription
|
localError = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -326,10 +744,12 @@ struct SwapStatusBadge: View {
|
|||||||
var status: String
|
var status: String
|
||||||
|
|
||||||
private var colour: Color {
|
private var colour: Color {
|
||||||
switch status {
|
switch status.uppercased() {
|
||||||
case "FINISHED": return Theme.success
|
case "FINISHED", "SUCCESS": return Theme.success
|
||||||
case "WAITING", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
|
case "WAITING", "NEW", "CONFIRMING", "EXCHANGING", "SENDING": return Theme.accent
|
||||||
default: return Theme.danger
|
case "REFUNDED": return Theme.muted
|
||||||
|
case "FAILED", "EXPIRED", "OVERDUE": return Theme.danger
|
||||||
|
default: return Theme.muted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
PrimaryButton(title: "Done", action: onDone)
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
|
|||||||
@@ -313,12 +313,14 @@ final class MoneroService {
|
|||||||
|
|
||||||
// MARK: - Sends
|
// 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 atomic = try MoneroAmount.atomic(fromXMR: amountXMR)
|
||||||
let response = try await invoke(["op": "transferTo",
|
let response = try await invoke(["op": "transferTo",
|
||||||
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
|
"address": address.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
"amount": String(atomic),
|
"amount": String(atomic),
|
||||||
"priority": priority.rawValue])
|
"priority": priority.rawValue,
|
||||||
|
"accountIndex": accountIndex])
|
||||||
return (response["txHash"] as? String ?? "",
|
return (response["txHash"] as? String ?? "",
|
||||||
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
|
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,24 @@ import Foundation
|
|||||||
|
|
||||||
/// A route offered by one of kyc.rip's underlying exchange engines.
|
/// A route offered by one of kyc.rip's underlying exchange engines.
|
||||||
struct SwapRoute: Identifiable {
|
struct SwapRoute: Identifiable {
|
||||||
var id: String { provider + engine }
|
var id: String { "\(provider).\(engine).\(amountTo)" }
|
||||||
var provider: String
|
var provider: String
|
||||||
var engine: String
|
var engine: String
|
||||||
var amountFrom: Double
|
var amountFrom: Double
|
||||||
var amountTo: Double
|
var amountTo: Double
|
||||||
var eta: Int? // minutes
|
var eta: Int? // minutes
|
||||||
var fixedRate: Bool
|
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 {
|
struct SwapEstimate {
|
||||||
|
var id: String
|
||||||
var amountTo: Double
|
var amountTo: Double
|
||||||
var routes: [SwapRoute]
|
var routes: [SwapRoute]
|
||||||
}
|
}
|
||||||
@@ -33,8 +40,27 @@ struct SwapOrder: Codable, Identifiable {
|
|||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var eta: Int?
|
var eta: Int?
|
||||||
|
|
||||||
/// Latest-known lifecycle state (not persisted).
|
/// Latest-known lifecycle state (refreshed from the API).
|
||||||
var status: String?
|
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 {
|
enum SwapError: LocalizedError {
|
||||||
@@ -94,14 +120,21 @@ final class SwapService {
|
|||||||
amountTo: (entry["amount_to"] as? NSNumber)?.doubleValue ?? 0,
|
amountTo: (entry["amount_to"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
eta: (entry["eta"] as? NSNumber)?.intValue,
|
eta: (entry["eta"] as? NSNumber)?.intValue,
|
||||||
fixedRate: (entry["fixed"] as? Bool) ?? false,
|
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
|
// MARK: - Orders
|
||||||
|
|
||||||
func createOrder(route: SwapRoute,
|
func createOrder(route: SwapRoute,
|
||||||
|
estimateID: String,
|
||||||
from: String,
|
from: String,
|
||||||
to: String,
|
to: String,
|
||||||
addressTo: String,
|
addressTo: String,
|
||||||
@@ -111,7 +144,7 @@ final class SwapService {
|
|||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
let body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"provider": route.provider,
|
"provider": route.provider,
|
||||||
"engine": route.engine,
|
"engine": route.engine,
|
||||||
"from_currency": from,
|
"from_currency": from,
|
||||||
@@ -120,7 +153,15 @@ final class SwapService {
|
|||||||
"to_network": "Mainnet",
|
"to_network": "Mainnet",
|
||||||
"address_to": addressTo,
|
"address_to": addressTo,
|
||||||
"amount_from": amountFrom,
|
"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)
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
@@ -141,16 +182,18 @@ final class SwapService {
|
|||||||
depositAddress: json["depositAddress"] as? String ?? "",
|
depositAddress: json["depositAddress"] as? String ?? "",
|
||||||
depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
|
depositAmount: (json["depositAmount"] as? NSNumber)?.doubleValue ?? amountFrom,
|
||||||
depositMemo: json["depositMemo"] as? String,
|
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,
|
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,
|
toAmount: (json["toAmount"] as? NSNumber)?.doubleValue ?? 0,
|
||||||
addressTo: json["address_user"] as? String ?? addressTo,
|
addressTo: json["address_user"] as? String ?? addressTo,
|
||||||
createdAt: Date(),
|
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)")
|
var components = URLComponents(string: "\(base)/v2/exchange/status/\(order.id)")
|
||||||
components?.queryItems = [URLQueryItem(name: "engine", value: order.engine)]
|
components?.queryItems = [URLQueryItem(name: "engine", value: order.engine)]
|
||||||
guard let url = components?.url else { throw SwapError.noRoute }
|
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) }
|
if let error = json["error"] as? String { throw SwapError.api(error) }
|
||||||
|
|
||||||
let txIn = json["txIn"] as? String
|
let txIn = nonEmpty(json["txIn"] as? String)
|
||||||
?? (json["txIn"] as? [String: Any]).flatMap { $0["hash"] as? String }
|
?? (json["txIn"] as? [String: Any]).flatMap { nonEmpty($0["hash"] as? String) }
|
||||||
let txOut = json["txOut"] as? String
|
let txOut = nonEmpty(json["txOut"] as? String)
|
||||||
?? (json["txOut"] as? [String: Any]).flatMap { $0["hash"] 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 {
|
private func check(_ response: URLResponse) throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user