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%
13.0 KB · 341 lines swift
Raw Blame History
1//2//  TronView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112/// Tron panel: USDT (TRC-20) + TRX balances, receive, send with the energy13/// burn estimate surfaced before signing, Nile/mainnet switch.14struct TronView: 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 sendUSDT = true22    @State private var recipient = ""23    @State private var amountInput = ""24    @State private var prepared: TronService.PreparedTronSend?25    @State private var password = ""26    @State private var sending = false27    @State private var estimating = false28    @State private var sentTxid: String?29    @State private var errorMessage: String?3031    var body: some View {32        VStack(alignment: .leading, spacing: 16) {33            header34            switch mode {35            case .overview: overview36            case .receive: receive37            case .send: send38            }39        }40        .padding(24)41        .frame(width: 470)42    }4344    private var header: some View {45        HStack {46            Text("Tron").font(.title2.bold())47            Spacer()48            Text(app.tronNetwork.isTestnet ? "NILE · test" : "MAINNET")49                .font(.caption.weight(.bold))50                .padding(.horizontal, 10).padding(.vertical, 4)51                .background(app.tronNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.red.opacity(0.22))52                .foregroundStyle(app.tronNetwork.isTestnet ? Color.orange : Color.red)53                .clipShape(Capsule())54            Button("Done") { dismiss() }55        }56    }5758    // MARK: - Overview5960    private var overview: some View {61        VStack(alignment: .leading, spacing: 14) {62            VStack(alignment: .leading, spacing: 10) {63                HStack(alignment: .firstTextBaseline) {64                    Text("USDT").font(.headline)65                    Spacer()66                    VStack(alignment: .trailing, spacing: 2) {67                        Text(TokenAmount.format(BigUInt(app.tronBalances?.usdtUnits ?? 0), decimals: 6))68                            .font(.system(size: 26, weight: .bold, design: .rounded))69                            .monospacedDigit()70                        if let fiat = fiatLine(units: app.tronBalances?.usdtUnits ?? 0, decimals: 6, symbol: "USDT") {71                            Text(fiat).font(.caption).foregroundStyle(.secondary)72                        }73                    }74                }75                Divider()76                HStack {77                    Text("TRX (fees)").font(.subheadline).foregroundStyle(.secondary)78                    Spacer()79                    VStack(alignment: .trailing, spacing: 2) {80                        Text(TronService.formatTRX(app.tronBalances?.trxSun ?? 0) + " TRX")81                            .font(.subheadline.monospaced())82                            .foregroundStyle(.secondary)83                        if let fiat = fiatLine(units: app.tronBalances?.trxSun ?? 0, decimals: 6, symbol: "TRX") {84                            Text(fiat).font(.caption).foregroundStyle(.secondary)85                        }86                    }87                }88                if let error = app.tronError {89                    Label(error, systemImage: "wifi.exclamationmark")90                        .font(.caption).foregroundStyle(.orange)91                }92            }93            .padding(14)94            .background(.quaternary.opacity(0.4))95            .clipShape(RoundedRectangle(cornerRadius: 10))9697            HStack(spacing: 12) {98                Button {99                    mode = .send100                } label: {101                    Label("Send", systemImage: "arrow.up.circle.fill").frame(maxWidth: .infinity)102                }103                .buttonStyle(.borderedProminent)104                Button {105                    mode = .receive106                } label: {107                    Label("Receive", systemImage: "arrow.down.circle").frame(maxWidth: .infinity)108                }109                Button {110                    Task { await app.refreshTron() }111                } label: {112                    Image(systemName: "arrow.clockwise")113                }114                .help("Refresh")115            }116117            Text("secp256k1 at m/44'/195'/0'/0/0 from your existing recovery phrase. USDT transfers burn TRX for energy (~13–27 TRX without staked energy) — the exact estimate is shown before you sign.")118                .font(.caption)119                .foregroundStyle(.secondary)120121            Picker("Network", selection: Binding(122                get: { app.tronNetwork },123                set: { newValue in Task { await app.switchTronNetwork(to: newValue) } }124            )) {125                ForEach(TronService.TronNetwork.allCases, id: \.self) { network in126                    Text(network.displayName).tag(network)127                }128            }129            .pickerStyle(.segmented)130        }131    }132133    private func fiatLine(units: UInt64, decimals: Int, symbol: String) -> String? {134        guard app.pricesEnabled, units > 0, let price = app.fiatPrices[symbol] else { return nil }135        let value = PriceService.fiatValue(units: BigUInt(units), decimals: decimals, price: price)136        return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency)137    }138139    // MARK: - Receive140141    private var receive: some View {142        VStack(spacing: 14) {143            if let address = app.tronAddress {144                if let qr = QRCode.image(for: address) {145                    Image(nsImage: qr)146                        .interpolation(.none)147                        .resizable()148                        .frame(width: 200, height: 200)149                        .background(.white)150                        .clipShape(RoundedRectangle(cornerRadius: 8))151                }152                Text(address)153                    .font(.callout.monospaced())154                    .textSelection(.enabled)155                    .padding(8)156                    .background(.quaternary.opacity(0.4))157                    .clipShape(RoundedRectangle(cornerRadius: 8))158                CopyButton(text: address)159                Text("One address for TRX and every TRC-20 token on \(app.tronNetwork.displayName).")160                    .font(.caption).foregroundStyle(.secondary)161            }162            Button("Back") { mode = .overview }163        }164        .frame(maxWidth: .infinity)165    }166167    // MARK: - Send168169    private var send: some View {170        VStack(alignment: .leading, spacing: 12) {171            if let txid = sentTxid {172                sentView(txid)173            } else if let prepared {174                confirmView(prepared)175            } else {176                sendForm177            }178        }179    }180181    private var sendForm: some View {182        VStack(alignment: .leading, spacing: 12) {183            Picker("Asset", selection: $sendUSDT) {184                Text("USDT").tag(true)185                Text("TRX").tag(false)186            }187            .pickerStyle(.segmented)188            TextField("Recipient (T…)", text: $recipient)189                .textFieldStyle(.roundedBorder)190                .font(.body.monospaced())191                .autocorrectionDisabled()192            if !recipient.isEmpty && !TronService.validate(address: recipient) {193                Label("Not a valid Tron address", systemImage: "xmark.circle")194                    .font(.caption).foregroundStyle(.red)195            }196            HStack {197                TextField("Amount", text: $amountInput)198                    .textFieldStyle(.roundedBorder)199                    .font(.body.monospaced())200                Text(sendUSDT ? "USDT" : "TRX").foregroundStyle(.secondary)201            }202            Text(sendUSDT203                 ? "Balance: \(TokenAmount.format(BigUInt(app.tronBalances?.usdtUnits ?? 0), decimals: 6)) USDT"204                 : "Balance: \(TronService.formatTRX(app.tronBalances?.trxSun ?? 0)) TRX")205                .font(.caption).foregroundStyle(.secondary)206            if let errorMessage {207                Text(errorMessage).foregroundStyle(.red).font(.callout)208            }209            HStack {210                Button("Back") { mode = .overview; errorMessage = nil }211                Spacer()212                if estimating { ProgressView().controlSize(.small) }213                Button("Review") { estimate() }214                    .buttonStyle(.borderedProminent)215                    .disabled(!formValid || estimating)216            }217        }218    }219220    private var parsedAmount: UInt64? {221        sendUSDT ? TronService.parseUSDT(amountInput) : TronService.parseTRX(amountInput)222    }223224    private var formValid: Bool {225        TronService.validate(address: recipient) && (parsedAmount ?? 0) > 0226    }227228    private func estimate() {229        guard let amount = parsedAmount else { return }230        errorMessage = nil231        estimating = true232        let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines)233        let usdt = sendUSDT234        Task {235            do {236                prepared = try await app.tronService.estimateSend(237                    to: to, amountUnits: amount, isUSDT: usdt)238            } catch {239                errorMessage = error.localizedDescription240            }241            estimating = false242        }243    }244245    private func confirmView(_ p: TronService.PreparedTronSend) -> some View {246        VStack(alignment: .leading, spacing: 12) {247            Text("Confirm transaction").font(.headline)248            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {249                GridRow {250                    Text("Recipient").foregroundStyle(.secondary)251                    Text(p.recipient).font(.callout.monospaced())252                        .textSelection(.enabled)253                        .lineLimit(1).truncationMode(.middle)254                }255                GridRow {256                    Text("Amount").foregroundStyle(.secondary)257                    Text(p.isUSDT258                         ? "\(TokenAmount.format(BigUInt(p.amountUnits), decimals: 6)) USDT"259                         : "\(TronService.formatTRX(p.amountUnits)) TRX")260                        .fontWeight(.semibold)261                }262                GridRow {263                    Text("Network").foregroundStyle(.secondary)264                    Text(p.network.displayName)265                }266                GridRow {267                    Text("Est. fee").foregroundStyle(.secondary)268                    Text("≤ \(TronService.formatTRX(p.estimatedFeeSun)) TRX (burned)")269                }270            }271            .font(.callout)272            if p.isUSDT, (app.tronBalances?.trxSun ?? 0) < p.estimatedFeeSun {273                Label("Not enough TRX to cover the energy burn. Top up TRX first.",274                      systemImage: "exclamationmark.triangle")275                    .font(.caption)276                    .foregroundStyle(.red)277            }278            Divider()279            SecureField("Vault password to sign", text: $password)280                .textFieldStyle(.roundedBorder)281            if let errorMessage {282                Text(errorMessage).foregroundStyle(.red).font(.callout)283            }284            HStack {285                Button("Back") {286                    prepared = nil287                    password = ""288                }289                Spacer()290                if sending { ProgressView().controlSize(.small) }291                Button("Sign & send") { broadcast(p) }292                    .buttonStyle(.borderedProminent)293                    .disabled(password.isEmpty || sending)294            }295        }296    }297298    private func broadcast(_ p: TronService.PreparedTronSend) {299        sending = true300        errorMessage = nil301        let candidate = password302        let manager = app.keyManager303        Task {304            do {305                let mnemonic = try await Task.detached {306                    try manager.unlock(password: candidate).mnemonic307                }.value308                let txid = try await app.tronService.send(p, mnemonic: mnemonic)309                password = ""310                sentTxid = txid311                await app.refreshTron()312            } catch {313                errorMessage = error.localizedDescription314            }315            sending = false316        }317    }318319    private func sentView(_ txid: String) -> some View {320        VStack(spacing: 12) {321            Image(systemName: "paperplane.circle.fill")322                .font(.system(size: 38)).foregroundStyle(.green)323            Text("Transaction sent").font(.headline)324            Text(txid)325                .font(.caption.monospaced())326                .textSelection(.enabled)327                .lineLimit(1).truncationMode(.middle)328            Link("View on Tronscan", destination: app.tronNetwork.explorerTxURL(txid))329            Button("Done") {330                sentTxid = nil331                prepared = nil332                recipient = ""333                amountInput = ""334                mode = .overview335            }336            .buttonStyle(.borderedProminent)337        }338        .frame(maxWidth: .infinity)339    }340}341