SPB Git

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%
15.1 KB · 390 lines swift
Raw Blame History
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