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%
12.9 KB · 338 lines swift
Raw Blame History
1//2//  SolanaView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112/// Solana panel: SOL + USDC balances, receive, send (with ATA-rent warning13/// when the recipient has no USDC account yet), devnet/mainnet switch.14/// Sends follow the vault flow: password → derive keypair → sign → discard.15struct SolanaView: View {16    @EnvironmentObject var app: AppState17    @Environment(\.dismiss) private var dismiss1819    enum Mode { case overview, receive, send }20    @State private var mode: Mode = .overview2122    @State private var sendUSDC = true23    @State private var recipient = ""24    @State private var amountInput = ""25    @State private var prepared: SolanaService.PreparedSOLSend?26    @State private var password = ""27    @State private var sending = false28    @State private var sentSignature: 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("Solana").font(.title2.bold())47            Spacer()48            Text(app.solNetwork.isTestnet ? "DEVNET · test" : "MAINNET")49                .font(.caption.weight(.bold))50                .padding(.horizontal, 10).padding(.vertical, 4)51                .background(app.solNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.purple.opacity(0.25))52                .foregroundStyle(app.solNetwork.isTestnet ? Color.orange : Color.purple)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("USDC").font(.headline)65                    Spacer()66                    VStack(alignment: .trailing, spacing: 2) {67                        Text(TokenAmount.format(BigUInt(app.solBalances?.usdcUnits ?? 0), decimals: 6))68                            .font(.system(size: 26, weight: .bold, design: .rounded))69                            .monospacedDigit()70                        if let fiat = fiatLine(units: app.solBalances?.usdcUnits ?? 0, decimals: 6, symbol: "USDC") {71                            Text(fiat).font(.caption).foregroundStyle(.secondary)72                        }73                    }74                }75                Divider()76                HStack {77                    Text("SOL (fees)").font(.subheadline).foregroundStyle(.secondary)78                    Spacer()79                    VStack(alignment: .trailing, spacing: 2) {80                        Text(SolanaService.formatSOL(app.solBalances?.lamports ?? 0) + " SOL")81                            .font(.subheadline.monospaced())82                            .foregroundStyle(.secondary)83                        if let fiat = fiatLine(units: app.solBalances?.lamports ?? 0, decimals: 9, symbol: "SOL") {84                            Text(fiat).font(.caption).foregroundStyle(.secondary)85                        }86                    }87                }88                if let error = app.solError {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.refreshSolana() }111                } label: {112                    Image(systemName: "arrow.clockwise")113                }114                .help("Refresh")115            }116117            Text("ed25519 at m/44'/501'/0'/0' from your existing recovery phrase (Phantom-compatible). Fees are paid in SOL.")118                .font(.caption)119                .foregroundStyle(.secondary)120121            Picker("Network", selection: Binding(122                get: { app.solNetwork },123                set: { newValue in Task { await app.switchSolanaNetwork(to: newValue) } }124            )) {125                ForEach(SolanaService.SOLNetwork.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.solAddress {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 SOL and every SPL token on \(app.solNetwork.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 signature = sentSignature {172                sentView(signature)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: $sendUSDC) {184                Text("USDC").tag(true)185                Text("SOL").tag(false)186            }187            .pickerStyle(.segmented)188            TextField("Recipient (Solana address)", text: $recipient)189                .textFieldStyle(.roundedBorder)190                .font(.body.monospaced())191                .autocorrectionDisabled()192            if !recipient.isEmpty && !SolanaService.validate(address: recipient) {193                Label("Not a valid Solana 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(sendUSDC ? "USDC" : "SOL").foregroundStyle(.secondary)201            }202            Text(sendUSDC203                 ? "Balance: \(TokenAmount.format(BigUInt(app.solBalances?.usdcUnits ?? 0), decimals: 6)) USDC"204                 : "Balance: \(SolanaService.formatSOL(app.solBalances?.lamports ?? 0)) SOL")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                Button("Review") { estimate() }213                    .buttonStyle(.borderedProminent)214                    .disabled(!formValid)215            }216        }217    }218219    private var parsedAmount: UInt64? {220        sendUSDC ? SolanaService.parseUSDC(amountInput) : SolanaService.parseSOL(amountInput)221    }222223    private var formValid: Bool {224        SolanaService.validate(address: recipient) && (parsedAmount ?? 0) > 0225    }226227    private func estimate() {228        guard let amount = parsedAmount else { return }229        errorMessage = nil230        let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines)231        let usdc = sendUSDC232        Task {233            do {234                prepared = try await app.solanaService.estimateSend(235                    to: to, amountUnits: amount, isUSDC: usdc)236            } catch {237                errorMessage = error.localizedDescription238            }239        }240    }241242    private func confirmView(_ p: SolanaService.PreparedSOLSend) -> some View {243        VStack(alignment: .leading, spacing: 12) {244            Text("Confirm transaction").font(.headline)245            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {246                GridRow {247                    Text("Recipient").foregroundStyle(.secondary)248                    Text(p.recipient).font(.callout.monospaced())249                        .textSelection(.enabled)250                        .lineLimit(1).truncationMode(.middle)251                }252                GridRow {253                    Text("Amount").foregroundStyle(.secondary)254                    Text(p.isUSDC255                         ? "\(TokenAmount.format(BigUInt(p.amountUnits), decimals: 6)) USDC"256                         : "\(SolanaService.formatSOL(p.amountUnits)) SOL")257                        .fontWeight(.semibold)258                }259                GridRow {260                    Text("Network").foregroundStyle(.secondary)261                    Text(p.network.displayName)262                }263                GridRow {264                    Text("Est. fee").foregroundStyle(.secondary)265                    Text("\(SolanaService.formatSOL(p.estimatedFeeLamports)) SOL")266                }267            }268            .font(.callout)269            if p.createsTokenAccount {270                Label("The recipient has no USDC account yet — you fund its creation (~0.002 SOL rent, included in the fee above).",271                      systemImage: "info.circle")272                    .font(.caption)273                    .foregroundStyle(.orange)274            }275            Divider()276            SecureField("Vault password to sign", text: $password)277                .textFieldStyle(.roundedBorder)278            if let errorMessage {279                Text(errorMessage).foregroundStyle(.red).font(.callout)280            }281            HStack {282                Button("Back") {283                    prepared = nil284                    password = ""285                }286                Spacer()287                if sending { ProgressView().controlSize(.small) }288                Button("Sign & send") { broadcast(p) }289                    .buttonStyle(.borderedProminent)290                    .disabled(password.isEmpty || sending)291            }292        }293    }294295    private func broadcast(_ p: SolanaService.PreparedSOLSend) {296        sending = true297        errorMessage = nil298        let candidate = password299        let manager = app.keyManager300        Task {301            do {302                let mnemonic = try await Task.detached {303                    try manager.unlock(password: candidate).mnemonic304                }.value305                let signature = try await app.solanaService.send(p, mnemonic: mnemonic)306                password = ""307                sentSignature = signature308                await app.refreshSolana()309            } catch {310                errorMessage = error.localizedDescription311            }312            sending = false313        }314    }315316    private func sentView(_ signature: String) -> some View {317        VStack(spacing: 12) {318            Image(systemName: "paperplane.circle.fill")319                .font(.system(size: 38)).foregroundStyle(.green)320            Text("Transaction sent").font(.headline)321            Text(signature)322                .font(.caption.monospaced())323                .textSelection(.enabled)324                .lineLimit(1).truncationMode(.middle)325            Link("View on Solana Explorer", destination: app.solNetwork.explorerTxURL(signature))326            Button("Done") {327                sentSignature = nil328                prepared = nil329                recipient = ""330                amountInput = ""331                mode = .overview332            }333            .buttonStyle(.borderedProminent)334        }335        .frame(maxWidth: .infinity)336    }337}338