// // XRPLView.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI import BigInt /// XRP Ledger panel: XRP + RLUSD balances (reserve shown as locked), receive, /// send with trustline checks, one-tap RLUSD trustline, testnet/mainnet switch. struct XRPLView: View { @EnvironmentObject var app: AppState @Environment(\.dismiss) private var dismiss enum Mode { case overview, receive, send } @State private var mode: Mode = .overview @State private var sendRLUSD = false @State private var recipient = "" @State private var amountInput = "" @State private var prepared: XRPLService.PreparedXRPLSend? @State private var password = "" @State private var trustlinePassword = "" @State private var showTrustline = false @State private var busy = false @State private var sentHash: String? @State private var errorMessage: String? var body: some View { VStack(alignment: .leading, spacing: 16) { header switch mode { case .overview: overview case .receive: receive case .send: send } } .padding(24) .frame(width: 470) } private var header: some View { HStack { Text("XRP Ledger").font(.title2.bold()) Spacer() Text(app.xrplNetwork.isTestnet ? "TESTNET" : "MAINNET") .font(.caption.weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 4) .background(app.xrplNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.blue.opacity(0.2)) .foregroundStyle(app.xrplNetwork.isTestnet ? Color.orange : Color.blue) .clipShape(Capsule()) Button("Done") { dismiss() } } } // MARK: - Overview private var overview: some View { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { Text("XRP").font(.headline) Spacer() VStack(alignment: .trailing, spacing: 2) { Text(XRPLService.formatXRP(app.xrplBalances?.drops ?? 0)) .font(.system(size: 26, weight: .bold, design: .rounded)) .monospacedDigit() if let fiat = fiatLine(units: app.xrplBalances?.drops ?? 0, decimals: 6, symbol: "XRP") { Text(fiat).font(.caption).foregroundStyle(.secondary) } } } if let balances = app.xrplBalances, balances.accountExists { Text("Spendable: \(XRPLService.formatXRP(balances.spendableDrops)) XRP (\(XRPLService.formatXRP(balances.reserveDrops)) locked as reserve)") .font(.caption).foregroundStyle(.secondary) } Divider() HStack(alignment: .firstTextBaseline) { Text("RLUSD").font(.headline) Spacer() if app.xrplBalances?.hasRLUSDTrustline == true { Text(app.xrplBalances?.rlusdValue ?? "0") .font(.title3.weight(.semibold)).monospacedDigit() } else { Text("no trustline").font(.callout).foregroundStyle(.secondary) } } if app.xrplBalances?.accountExists == false { Label("Account not funded yet — the first deposit must be at least 1 XRP.", systemImage: "info.circle") .font(.caption).foregroundStyle(.orange) } if let error = app.xrplError { Label(error, systemImage: "wifi.exclamationmark") .font(.caption).foregroundStyle(.orange) } } .padding(14) .background(.quaternary.opacity(0.4)) .clipShape(RoundedRectangle(cornerRadius: 10)) HStack(spacing: 12) { Button { mode = .send } label: { Label("Send", systemImage: "arrow.up.circle.fill").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button { mode = .receive } label: { Label("Receive", systemImage: "arrow.down.circle").frame(maxWidth: .infinity) } Button { Task { await app.refreshXRPL() } } label: { Image(systemName: "arrow.clockwise") } .help("Refresh") } if app.xrplBalances?.hasRLUSDTrustline != true, app.xrplBalances?.accountExists == true { DisclosureGroup("Enable RLUSD (create trustline)", isExpanded: $showTrustline) { VStack(alignment: .leading, spacing: 8) { Text("A one-time transaction that lets this account hold RLUSD. Locks 0.2 XRP of reserve.") .font(.caption).foregroundStyle(.secondary) HStack { SecureField("Vault password", text: $trustlinePassword) .textFieldStyle(.roundedBorder) Button("Enable") { createTrustline() } .disabled(trustlinePassword.isEmpty || busy) } if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } } .padding(.top, 6) } .font(.callout) } Text("secp256k1 at m/44'/144'/0'/0/0 from your existing recovery phrase. Fees are ~12 drops (0.000012 XRP).") .font(.caption) .foregroundStyle(.secondary) Picker("Network", selection: Binding( get: { app.xrplNetwork }, set: { newValue in Task { await app.switchXRPLNetwork(to: newValue) } } )) { ForEach(XRPLService.XRPLNetwork.allCases, id: \.self) { network in Text(network.displayName).tag(network) } } .pickerStyle(.segmented) } } private func fiatLine(units: UInt64, decimals: Int, symbol: String) -> String? { guard app.pricesEnabled, units > 0, let price = app.fiatPrices[symbol] else { return nil } let value = PriceService.fiatValue(units: BigUInt(units), decimals: decimals, price: price) return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency) } private func createTrustline() { busy = true errorMessage = nil let candidate = trustlinePassword let manager = app.keyManager Task { do { let mnemonic = try await Task.detached { try manager.unlock(password: candidate).mnemonic }.value _ = try await app.xrplService.createRLUSDTrustline(mnemonic: mnemonic) trustlinePassword = "" showTrustline = false await app.refreshXRPL() } catch { errorMessage = error.localizedDescription } busy = false } } // MARK: - Receive private var receive: some View { VStack(spacing: 14) { if let address = app.xrplAddress { if let qr = QRCode.image(for: address) { Image(nsImage: qr) .interpolation(.none) .resizable() .frame(width: 200, height: 200) .background(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } Text(address) .font(.callout.monospaced()) .textSelection(.enabled) .padding(8) .background(.quaternary.opacity(0.4)) .clipShape(RoundedRectangle(cornerRadius: 8)) CopyButton(text: address) Text("First deposit must be ≥ 1 XRP (activates the account). RLUSD requires the trustline.") .font(.caption).foregroundStyle(.secondary) .multilineTextAlignment(.center) } Button("Back") { mode = .overview } } .frame(maxWidth: .infinity) } // MARK: - Send private var send: some View { VStack(alignment: .leading, spacing: 12) { if let hash = sentHash { sentView(hash) } else if let prepared { confirmView(prepared) } else { sendForm } } } private var sendForm: some View { VStack(alignment: .leading, spacing: 12) { Picker("Asset", selection: $sendRLUSD) { Text("XRP").tag(false) Text("RLUSD").tag(true) } .pickerStyle(.segmented) TextField("Recipient (r…)", text: $recipient) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) .autocorrectionDisabled() if !recipient.isEmpty && !XRPLService.validate(address: recipient) { Label("Not a valid XRPL address", systemImage: "xmark.circle") .font(.caption).foregroundStyle(.red) } HStack { TextField("Amount", text: $amountInput) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) Text(sendRLUSD ? "RLUSD" : "XRP").foregroundStyle(.secondary) } Text(sendRLUSD ? "Balance: \(app.xrplBalances?.rlusdValue ?? "0") RLUSD" : "Spendable: \(XRPLService.formatXRP(app.xrplBalances?.spendableDrops ?? 0)) XRP") .font(.caption).foregroundStyle(.secondary) if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { mode = .overview; errorMessage = nil } Spacer() if busy { ProgressView().controlSize(.small) } Button("Review") { estimate() } .buttonStyle(.borderedProminent) .disabled(!formValid || busy) } } } private var formValid: Bool { guard XRPLService.validate(address: recipient) else { return false } if sendRLUSD { return XRPLService.validRLUSDAmount(amountInput) != nil } return (XRPLService.parseXRP(amountInput) ?? 0) > 0 } private func estimate() { errorMessage = nil busy = true let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines) let rlusd = sendRLUSD let drops = XRPLService.parseXRP(amountInput) ?? 0 let value = XRPLService.validRLUSDAmount(amountInput) ?? "0" Task { do { prepared = try await app.xrplService.estimateSend( to: to, amountDrops: drops, amountValue: value, isRLUSD: rlusd) } catch { errorMessage = error.localizedDescription } busy = false } } private func confirmView(_ p: XRPLService.PreparedXRPLSend) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Confirm transaction").font(.headline) Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("Recipient").foregroundStyle(.secondary) Text(p.recipient).font(.callout.monospaced()) .textSelection(.enabled) .lineLimit(1).truncationMode(.middle) } GridRow { Text("Amount").foregroundStyle(.secondary) Text(p.isRLUSD ? "\(p.amountValue) RLUSD" : "\(XRPLService.formatXRP(p.amountDrops)) XRP") .fontWeight(.semibold) } GridRow { Text("Network").foregroundStyle(.secondary) Text(p.network.displayName) } GridRow { Text("Fee").foregroundStyle(.secondary) Text("\(XRPLService.formatXRP(p.feeDrops)) XRP") } } .font(.callout) if p.activatesRecipient { Label("The recipient account doesn't exist yet — 1 XRP of the amount becomes its locked base reserve.", systemImage: "info.circle") .font(.caption) .foregroundStyle(.orange) } Divider() SecureField("Vault password to sign", text: $password) .textFieldStyle(.roundedBorder) if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { prepared = nil password = "" } Spacer() if busy { ProgressView().controlSize(.small) } Button("Sign & send") { broadcast(p) } .buttonStyle(.borderedProminent) .disabled(password.isEmpty || busy) } } } private func broadcast(_ p: XRPLService.PreparedXRPLSend) { busy = true errorMessage = nil let candidate = password let manager = app.keyManager Task { do { let mnemonic = try await Task.detached { try manager.unlock(password: candidate).mnemonic }.value let hash = try await app.xrplService.send(p, mnemonic: mnemonic) password = "" sentHash = hash await app.refreshXRPL() } catch { errorMessage = error.localizedDescription } busy = false } } private func sentView(_ hash: String) -> some View { VStack(spacing: 12) { Image(systemName: "paperplane.circle.fill") .font(.system(size: 38)).foregroundStyle(.green) Text("Transaction sent").font(.headline) Text(hash) .font(.caption.monospaced()) .textSelection(.enabled) .lineLimit(1).truncationMode(.middle) Link("View on XRPL Explorer", destination: app.xrplNetwork.explorerTxURL(hash)) Button("Done") { sentHash = nil prepared = nil recipient = "" amountInput = "" mode = .overview } .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity) } }