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// 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