spb/os-vault Public
Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.
Swift 96%
Shell 3.4%
Makefile 0.6%
1//2// XRPLView.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112/// XRP Ledger panel: XRP + RLUSD balances (reserve shown as locked), receive,13/// send with trustline checks, one-tap RLUSD trustline, testnet/mainnet switch.14struct XRPLView: View {15 @EnvironmentObject var app: AppState16 @Environment(\.dismiss) private var dismiss1718 enum Mode { case overview, receive, send }19 @State private var mode: Mode = .overview2021 @State private var sendRLUSD = false22 @State private var recipient = ""23 @State private var amountInput = ""24 @State private var prepared: XRPLService.PreparedXRPLSend?25 @State private var password = ""26 @State private var trustlinePassword = ""27 @State private var showTrustline = false28 @State private var busy = false29 @State private var sentHash: String?30 @State private var errorMessage: String?3132 var body: some View {33 VStack(alignment: .leading, spacing: 16) {34 header35 switch mode {36 case .overview: overview37 case .receive: receive38 case .send: send39 }40 }41 .padding(24)42 .frame(width: 470)43 }4445 private var header: some View {46 HStack {47 Text("XRP Ledger").font(.title2.bold())48 Spacer()49 Text(app.xrplNetwork.isTestnet ? "TESTNET" : "MAINNET")50 .font(.caption.weight(.bold))51 .padding(.horizontal, 10).padding(.vertical, 4)52 .background(app.xrplNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.blue.opacity(0.2))53 .foregroundStyle(app.xrplNetwork.isTestnet ? Color.orange : Color.blue)54 .clipShape(Capsule())55 Button("Done") { dismiss() }56 }57 }5859 // MARK: - Overview6061 private var overview: some View {62 VStack(alignment: .leading, spacing: 14) {63 VStack(alignment: .leading, spacing: 10) {64 HStack(alignment: .firstTextBaseline) {65 Text("XRP").font(.headline)66 Spacer()67 VStack(alignment: .trailing, spacing: 2) {68 Text(XRPLService.formatXRP(app.xrplBalances?.drops ?? 0))69 .font(.system(size: 26, weight: .bold, design: .rounded))70 .monospacedDigit()71 if let fiat = fiatLine(units: app.xrplBalances?.drops ?? 0, decimals: 6, symbol: "XRP") {72 Text(fiat).font(.caption).foregroundStyle(.secondary)73 }74 }75 }76 if let balances = app.xrplBalances, balances.accountExists {77 Text("Spendable: \(XRPLService.formatXRP(balances.spendableDrops)) XRP (\(XRPLService.formatXRP(balances.reserveDrops)) locked as reserve)")78 .font(.caption).foregroundStyle(.secondary)79 }80 Divider()81 HStack(alignment: .firstTextBaseline) {82 Text("RLUSD").font(.headline)83 Spacer()84 if app.xrplBalances?.hasRLUSDTrustline == true {85 Text(app.xrplBalances?.rlusdValue ?? "0")86 .font(.title3.weight(.semibold)).monospacedDigit()87 } else {88 Text("no trustline").font(.callout).foregroundStyle(.secondary)89 }90 }91 if app.xrplBalances?.accountExists == false {92 Label("Account not funded yet — the first deposit must be at least 1 XRP.",93 systemImage: "info.circle")94 .font(.caption).foregroundStyle(.orange)95 }96 if let error = app.xrplError {97 Label(error, systemImage: "wifi.exclamationmark")98 .font(.caption).foregroundStyle(.orange)99 }100 }101 .padding(14)102 .background(.quaternary.opacity(0.4))103 .clipShape(RoundedRectangle(cornerRadius: 10))104105 HStack(spacing: 12) {106 Button {107 mode = .send108 } label: {109 Label("Send", systemImage: "arrow.up.circle.fill").frame(maxWidth: .infinity)110 }111 .buttonStyle(.borderedProminent)112 Button {113 mode = .receive114 } label: {115 Label("Receive", systemImage: "arrow.down.circle").frame(maxWidth: .infinity)116 }117 Button {118 Task { await app.refreshXRPL() }119 } label: {120 Image(systemName: "arrow.clockwise")121 }122 .help("Refresh")123 }124125 if app.xrplBalances?.hasRLUSDTrustline != true, app.xrplBalances?.accountExists == true {126 DisclosureGroup("Enable RLUSD (create trustline)", isExpanded: $showTrustline) {127 VStack(alignment: .leading, spacing: 8) {128 Text("A one-time transaction that lets this account hold RLUSD. Locks 0.2 XRP of reserve.")129 .font(.caption).foregroundStyle(.secondary)130 HStack {131 SecureField("Vault password", text: $trustlinePassword)132 .textFieldStyle(.roundedBorder)133 Button("Enable") { createTrustline() }134 .disabled(trustlinePassword.isEmpty || busy)135 }136 if let errorMessage {137 Text(errorMessage).foregroundStyle(.red).font(.callout)138 }139 }140 .padding(.top, 6)141 }142 .font(.callout)143 }144145 Text("secp256k1 at m/44'/144'/0'/0/0 from your existing recovery phrase. Fees are ~12 drops (0.000012 XRP).")146 .font(.caption)147 .foregroundStyle(.secondary)148149 Picker("Network", selection: Binding(150 get: { app.xrplNetwork },151 set: { newValue in Task { await app.switchXRPLNetwork(to: newValue) } }152 )) {153 ForEach(XRPLService.XRPLNetwork.allCases, id: \.self) { network in154 Text(network.displayName).tag(network)155 }156 }157 .pickerStyle(.segmented)158 }159 }160161 private func fiatLine(units: UInt64, decimals: Int, symbol: String) -> String? {162 guard app.pricesEnabled, units > 0, let price = app.fiatPrices[symbol] else { return nil }163 let value = PriceService.fiatValue(units: BigUInt(units), decimals: decimals, price: price)164 return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency)165 }166167 private func createTrustline() {168 busy = true169 errorMessage = nil170 let candidate = trustlinePassword171 let manager = app.keyManager172 Task {173 do {174 let mnemonic = try await Task.detached {175 try manager.unlock(password: candidate).mnemonic176 }.value177 _ = try await app.xrplService.createRLUSDTrustline(mnemonic: mnemonic)178 trustlinePassword = ""179 showTrustline = false180 await app.refreshXRPL()181 } catch {182 errorMessage = error.localizedDescription183 }184 busy = false185 }186 }187188 // MARK: - Receive189190 private var receive: some View {191 VStack(spacing: 14) {192 if let address = app.xrplAddress {193 if let qr = QRCode.image(for: address) {194 Image(nsImage: qr)195 .interpolation(.none)196 .resizable()197 .frame(width: 200, height: 200)198 .background(.white)199 .clipShape(RoundedRectangle(cornerRadius: 8))200 }201 Text(address)202 .font(.callout.monospaced())203 .textSelection(.enabled)204 .padding(8)205 .background(.quaternary.opacity(0.4))206 .clipShape(RoundedRectangle(cornerRadius: 8))207 CopyButton(text: address)208 Text("First deposit must be ≥ 1 XRP (activates the account). RLUSD requires the trustline.")209 .font(.caption).foregroundStyle(.secondary)210 .multilineTextAlignment(.center)211 }212 Button("Back") { mode = .overview }213 }214 .frame(maxWidth: .infinity)215 }216217 // MARK: - Send218219 private var send: some View {220 VStack(alignment: .leading, spacing: 12) {221 if let hash = sentHash {222 sentView(hash)223 } else if let prepared {224 confirmView(prepared)225 } else {226 sendForm227 }228 }229 }230231 private var sendForm: some View {232 VStack(alignment: .leading, spacing: 12) {233 Picker("Asset", selection: $sendRLUSD) {234 Text("XRP").tag(false)235 Text("RLUSD").tag(true)236 }237 .pickerStyle(.segmented)238 TextField("Recipient (r…)", text: $recipient)239 .textFieldStyle(.roundedBorder)240 .font(.body.monospaced())241 .autocorrectionDisabled()242 if !recipient.isEmpty && !XRPLService.validate(address: recipient) {243 Label("Not a valid XRPL address", systemImage: "xmark.circle")244 .font(.caption).foregroundStyle(.red)245 }246 HStack {247 TextField("Amount", text: $amountInput)248 .textFieldStyle(.roundedBorder)249 .font(.body.monospaced())250 Text(sendRLUSD ? "RLUSD" : "XRP").foregroundStyle(.secondary)251 }252 Text(sendRLUSD253 ? "Balance: \(app.xrplBalances?.rlusdValue ?? "0") RLUSD"254 : "Spendable: \(XRPLService.formatXRP(app.xrplBalances?.spendableDrops ?? 0)) XRP")255 .font(.caption).foregroundStyle(.secondary)256 if let errorMessage {257 Text(errorMessage).foregroundStyle(.red).font(.callout)258 }259 HStack {260 Button("Back") { mode = .overview; errorMessage = nil }261 Spacer()262 if busy { ProgressView().controlSize(.small) }263 Button("Review") { estimate() }264 .buttonStyle(.borderedProminent)265 .disabled(!formValid || busy)266 }267 }268 }269270 private var formValid: Bool {271 guard XRPLService.validate(address: recipient) else { return false }272 if sendRLUSD {273 return XRPLService.validRLUSDAmount(amountInput) != nil274 }275 return (XRPLService.parseXRP(amountInput) ?? 0) > 0276 }277278 private func estimate() {279 errorMessage = nil280 busy = true281 let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines)282 let rlusd = sendRLUSD283 let drops = XRPLService.parseXRP(amountInput) ?? 0284 let value = XRPLService.validRLUSDAmount(amountInput) ?? "0"285 Task {286 do {287 prepared = try await app.xrplService.estimateSend(288 to: to, amountDrops: drops, amountValue: value, isRLUSD: rlusd)289 } catch {290 errorMessage = error.localizedDescription291 }292 busy = false293 }294 }295296 private func confirmView(_ p: XRPLService.PreparedXRPLSend) -> some View {297 VStack(alignment: .leading, spacing: 12) {298 Text("Confirm transaction").font(.headline)299 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {300 GridRow {301 Text("Recipient").foregroundStyle(.secondary)302 Text(p.recipient).font(.callout.monospaced())303 .textSelection(.enabled)304 .lineLimit(1).truncationMode(.middle)305 }306 GridRow {307 Text("Amount").foregroundStyle(.secondary)308 Text(p.isRLUSD ? "\(p.amountValue) RLUSD" : "\(XRPLService.formatXRP(p.amountDrops)) XRP")309 .fontWeight(.semibold)310 }311 GridRow {312 Text("Network").foregroundStyle(.secondary)313 Text(p.network.displayName)314 }315 GridRow {316 Text("Fee").foregroundStyle(.secondary)317 Text("\(XRPLService.formatXRP(p.feeDrops)) XRP")318 }319 }320 .font(.callout)321 if p.activatesRecipient {322 Label("The recipient account doesn't exist yet — 1 XRP of the amount becomes its locked base reserve.",323 systemImage: "info.circle")324 .font(.caption)325 .foregroundStyle(.orange)326 }327 Divider()328 SecureField("Vault password to sign", text: $password)329 .textFieldStyle(.roundedBorder)330 if let errorMessage {331 Text(errorMessage).foregroundStyle(.red).font(.callout)332 }333 HStack {334 Button("Back") {335 prepared = nil336 password = ""337 }338 Spacer()339 if busy { ProgressView().controlSize(.small) }340 Button("Sign & send") { broadcast(p) }341 .buttonStyle(.borderedProminent)342 .disabled(password.isEmpty || busy)343 }344 }345 }346347 private func broadcast(_ p: XRPLService.PreparedXRPLSend) {348 busy = true349 errorMessage = nil350 let candidate = password351 let manager = app.keyManager352 Task {353 do {354 let mnemonic = try await Task.detached {355 try manager.unlock(password: candidate).mnemonic356 }.value357 let hash = try await app.xrplService.send(p, mnemonic: mnemonic)358 password = ""359 sentHash = hash360 await app.refreshXRPL()361 } catch {362 errorMessage = error.localizedDescription363 }364 busy = false365 }366 }367368 private func sentView(_ hash: String) -> some View {369 VStack(spacing: 12) {370 Image(systemName: "paperplane.circle.fill")371 .font(.system(size: 38)).foregroundStyle(.green)372 Text("Transaction sent").font(.headline)373 Text(hash)374 .font(.caption.monospaced())375 .textSelection(.enabled)376 .lineLimit(1).truncationMode(.middle)377 Link("View on XRPL Explorer", destination: app.xrplNetwork.explorerTxURL(hash))378 Button("Done") {379 sentHash = nil380 prepared = nil381 recipient = ""382 amountInput = ""383 mode = .overview384 }385 .buttonStyle(.borderedProminent)386 }387 .frame(maxWidth: .infinity)388 }389}390