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// TONView.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112/// TON panel: TON + USDT (jetton) balances, receive, send, testnet/mainnet13/// switch. USDT lives on mainnet only (no official testnet jetton).14struct TONView: 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 = false22 @State private var recipient = ""23 @State private var amountInput = ""24 @State private var prepared: TONService.PreparedTONSend?25 @State private var password = ""26 @State private var busy = false27 @State private var sentRef: String?28 @State private var errorMessage: String?2930 var body: some View {31 VStack(alignment: .leading, spacing: 16) {32 header33 switch mode {34 case .overview: overview35 case .receive: receive36 case .send: send37 }38 }39 .padding(24)40 .frame(width: 470)41 }4243 private var header: some View {44 HStack {45 Text("TON").font(.title2.bold())46 Spacer()47 Text(app.tonNetwork.isTestnet ? "TESTNET" : "MAINNET")48 .font(.caption.weight(.bold))49 .padding(.horizontal, 10).padding(.vertical, 4)50 .background(app.tonNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.cyan.opacity(0.2))51 .foregroundStyle(app.tonNetwork.isTestnet ? Color.orange : Color.cyan)52 .clipShape(Capsule())53 Button("Done") { dismiss() }54 }55 }5657 // MARK: - Overview5859 private var overview: some View {60 VStack(alignment: .leading, spacing: 14) {61 VStack(alignment: .leading, spacing: 10) {62 if app.tonNetwork.usdtAvailable {63 HStack(alignment: .firstTextBaseline) {64 Text("USDT").font(.headline)65 Spacer()66 VStack(alignment: .trailing, spacing: 2) {67 Text(TokenAmount.format(BigUInt(app.tonBalancesState?.usdtUnits ?? 0), decimals: 6))68 .font(.system(size: 26, weight: .bold, design: .rounded))69 .monospacedDigit()70 if let fiat = fiatLine(units: app.tonBalancesState?.usdtUnits ?? 0, decimals: 6, symbol: "USDT") {71 Text(fiat).font(.caption).foregroundStyle(.secondary)72 }73 }74 }75 Divider()76 }77 HStack {78 Text("TON (fees)").font(.subheadline).foregroundStyle(.secondary)79 Spacer()80 VStack(alignment: .trailing, spacing: 2) {81 Text(TONService.formatTON(app.tonBalancesState?.nanotons ?? 0) + " TON")82 .font(.subheadline.monospaced())83 .foregroundStyle(.secondary)84 if let fiat = fiatLine(units: app.tonBalancesState?.nanotons ?? 0, decimals: 9, symbol: "TON") {85 Text(fiat).font(.caption).foregroundStyle(.secondary)86 }87 }88 }89 if app.tonBalancesState?.deployed == false, (app.tonBalancesState?.nanotons ?? 0) > 0 {90 Text("Wallet contract deploys automatically with your first send.")91 .font(.caption).foregroundStyle(.secondary)92 }93 if let error = app.tonError {94 Label(error, systemImage: "wifi.exclamationmark")95 .font(.caption).foregroundStyle(.orange)96 }97 }98 .padding(14)99 .background(.quaternary.opacity(0.4))100 .clipShape(RoundedRectangle(cornerRadius: 10))101102 HStack(spacing: 12) {103 Button {104 mode = .send105 } label: {106 Label("Send", systemImage: "arrow.up.circle.fill").frame(maxWidth: .infinity)107 }108 .buttonStyle(.borderedProminent)109 Button {110 mode = .receive111 } label: {112 Label("Receive", systemImage: "arrow.down.circle").frame(maxWidth: .infinity)113 }114 Button {115 Task { await app.refreshTON() }116 } label: {117 Image(systemName: "arrow.clockwise")118 }119 .help("Refresh")120 }121122 Text("ed25519 (wallet v4R2) from your existing recovery phrase — note: Tonkeeper's default import expects TON-native 24-word phrases, not BIP-39. USDT is a jetton: sends attach ~0.07 TON for fees, the unused part is refunded.")123 .font(.caption)124 .foregroundStyle(.secondary)125126 Picker("Network", selection: Binding(127 get: { app.tonNetwork },128 set: { newValue in Task { await app.switchTONNetwork(to: newValue) } }129 )) {130 ForEach(TONService.TONNetwork.allCases, id: \.self) { network in131 Text(network.displayName).tag(network)132 }133 }134 .pickerStyle(.segmented)135 }136 }137138 private func fiatLine(units: UInt64, decimals: Int, symbol: String) -> String? {139 guard app.pricesEnabled, units > 0, let price = app.fiatPrices[symbol] else { return nil }140 let value = PriceService.fiatValue(units: BigUInt(units), decimals: decimals, price: price)141 return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency)142 }143144 // MARK: - Receive145146 private var receive: some View {147 VStack(spacing: 14) {148 if let address = app.tonAddress {149 if let qr = QRCode.image(for: address) {150 Image(nsImage: qr)151 .interpolation(.none)152 .resizable()153 .frame(width: 200, height: 200)154 .background(.white)155 .clipShape(RoundedRectangle(cornerRadius: 8))156 }157 Text(address)158 .font(.callout.monospaced())159 .textSelection(.enabled)160 .padding(8)161 .background(.quaternary.opacity(0.4))162 .clipShape(RoundedRectangle(cornerRadius: 8))163 CopyButton(text: address)164 Text("One address for TON and every jetton on \(app.tonNetwork.displayName).")165 .font(.caption).foregroundStyle(.secondary)166 }167 Button("Back") { mode = .overview }168 }169 .frame(maxWidth: .infinity)170 }171172 // MARK: - Send173174 private var send: some View {175 VStack(alignment: .leading, spacing: 12) {176 if let ref = sentRef {177 sentView(ref)178 } else if let prepared {179 confirmView(prepared)180 } else {181 sendForm182 }183 }184 }185186 private var sendForm: some View {187 VStack(alignment: .leading, spacing: 12) {188 if app.tonNetwork.usdtAvailable {189 Picker("Asset", selection: $sendUSDT) {190 Text("TON").tag(false)191 Text("USDT").tag(true)192 }193 .pickerStyle(.segmented)194 }195 TextField("Recipient (UQ… / EQ…)", text: $recipient)196 .textFieldStyle(.roundedBorder)197 .font(.body.monospaced())198 .autocorrectionDisabled()199 if !recipient.isEmpty && !TONService.validate(address: recipient) {200 Label("Not a valid TON address", systemImage: "xmark.circle")201 .font(.caption).foregroundStyle(.red)202 }203 HStack {204 TextField("Amount", text: $amountInput)205 .textFieldStyle(.roundedBorder)206 .font(.body.monospaced())207 Text(sendUSDT ? "USDT" : "TON").foregroundStyle(.secondary)208 }209 Text(sendUSDT210 ? "Balance: \(TokenAmount.format(BigUInt(app.tonBalancesState?.usdtUnits ?? 0), decimals: 6)) USDT"211 : "Balance: \(TONService.formatTON(app.tonBalancesState?.nanotons ?? 0)) TON")212 .font(.caption).foregroundStyle(.secondary)213 if let errorMessage {214 Text(errorMessage).foregroundStyle(.red).font(.callout)215 }216 HStack {217 Button("Back") { mode = .overview; errorMessage = nil }218 Spacer()219 if busy { ProgressView().controlSize(.small) }220 Button("Review") { estimate() }221 .buttonStyle(.borderedProminent)222 .disabled(!formValid || busy)223 }224 }225 }226227 private var parsedAmount: UInt64? {228 sendUSDT ? TONService.parseUSDT(amountInput) : TONService.parseTON(amountInput)229 }230231 private var formValid: Bool {232 TONService.validate(address: recipient) && (parsedAmount ?? 0) > 0233 }234235 private func estimate() {236 guard let amount = parsedAmount else { return }237 errorMessage = nil238 busy = true239 let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines)240 let usdt = sendUSDT241 Task {242 do {243 prepared = try await app.tonService.estimateSend(244 to: to, amountUnits: amount, isUSDT: usdt)245 } catch {246 errorMessage = error.localizedDescription247 }248 busy = false249 }250 }251252 private func confirmView(_ p: TONService.PreparedTONSend) -> some View {253 VStack(alignment: .leading, spacing: 12) {254 Text("Confirm transaction").font(.headline)255 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {256 GridRow {257 Text("Recipient").foregroundStyle(.secondary)258 Text(p.recipient).font(.callout.monospaced())259 .textSelection(.enabled)260 .lineLimit(1).truncationMode(.middle)261 }262 GridRow {263 Text("Amount").foregroundStyle(.secondary)264 Text(p.isUSDT265 ? "\(TokenAmount.format(BigUInt(p.amountUnits), decimals: 6)) USDT"266 : "\(TONService.formatTON(p.amountUnits)) TON")267 .fontWeight(.semibold)268 }269 GridRow {270 Text("Network").foregroundStyle(.secondary)271 Text(p.network.displayName)272 }273 GridRow {274 Text("Est. fee").foregroundStyle(.secondary)275 Text("≈ \(TONService.formatTON(p.estimatedFeeNanotons)) TON" + (p.isUSDT ? " (excess refunded)" : ""))276 }277 }278 .font(.callout)279 Divider()280 SecureField("Vault password to sign", text: $password)281 .textFieldStyle(.roundedBorder)282 if let errorMessage {283 Text(errorMessage).foregroundStyle(.red).font(.callout)284 }285 HStack {286 Button("Back") {287 prepared = nil288 password = ""289 }290 Spacer()291 if busy { ProgressView().controlSize(.small) }292 Button("Sign & send") { broadcast(p) }293 .buttonStyle(.borderedProminent)294 .disabled(password.isEmpty || busy)295 }296 }297 }298299 private func broadcast(_ p: TONService.PreparedTONSend) {300 busy = true301 errorMessage = nil302 let candidate = password303 let manager = app.keyManager304 Task {305 do {306 let mnemonic = try await Task.detached {307 try manager.unlock(password: candidate).mnemonic308 }.value309 let ref = try await app.tonService.send(p, mnemonic: mnemonic)310 password = ""311 sentRef = ref312 await app.refreshTON()313 } catch {314 errorMessage = error.localizedDescription315 }316 busy = false317 }318 }319320 private func sentView(_ ref: String) -> some View {321 VStack(spacing: 12) {322 Image(systemName: "paperplane.circle.fill")323 .font(.system(size: 38)).foregroundStyle(.green)324 Text("Transaction sent").font(.headline)325 Text("It will appear on the explorer within seconds.")326 .font(.callout).foregroundStyle(.secondary)327 if let address = app.tonAddress {328 Link("View account on Tonviewer", destination: app.tonNetwork.explorerAddressURL(address))329 }330 Button("Done") {331 sentRef = nil332 prepared = nil333 recipient = ""334 amountInput = ""335 mode = .overview336 }337 .buttonStyle(.borderedProminent)338 }339 .frame(maxWidth: .infinity)340 }341}342343extension TONService.TONNetwork {344 var usdtAvailable: Bool { usdtMaster != nil }345}346