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.2 KB · 346 lines swift
Raw Blame History
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