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%
11.8 KB · 325 lines swift
Raw Blame History
1//2//  SendView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112/// Send flow: token + recipient + amount → prepare (gas preview) →13/// confirmation screen showing recipient, amount, network, gas and total →14/// password re-entry unlocks the vault, signs, broadcasts, tracks the receipt.15struct SendView: View {16    @EnvironmentObject var app: AppState17    @Environment(\.dismiss) private var dismiss1819    enum Stage {20        case form21        case preparing22        case confirm(TransactionService.PreparedTransfer)23        case sending24        case sent(hash: String)25    }2627    @State private var stage: Stage = .form28    @State private var asset: Asset = .token(.usdc)29    @State private var recipientInput = ""30    @State private var amountInput = ""31    @State private var password = ""32    @State private var errorMessage: String?3334    var body: some View {35        VStack(alignment: .leading, spacing: 16) {36            HStack {37                Text("Send").font(.title2.bold())38                Spacer()39                NetworkBadge(network: app.network)40            }41            content42        }43        .padding(24)44        .frame(width: 460)45    }4647    @ViewBuilder48    private var content: some View {49        switch stage {50        case .form, .preparing:51            form52        case .confirm(let prepared):53            confirmView(prepared)54        case .sending:55            HStack {56                ProgressView().controlSize(.small)57                Text("Signing and broadcasting…")58            }59            .frame(maxWidth: .infinity, alignment: .center)60            .padding(.vertical, 30)61        case .sent(let hash):62            sentView(hash)63        }64    }6566    // MARK: - Form6768    private var recipientVerdict: AddressValidator.Verdict {69        AddressValidator.validate(recipientInput)70    }7172    private var form: some View {73        VStack(alignment: .leading, spacing: 14) {74            Picker("Asset", selection: $asset) {75                ForEach(Asset.available(on: app.network)) { candidate in76                    Text("\(candidate.symbol(on: app.network))\(candidate.name(on: app.network))")77                        .tag(candidate)78                }79            }8081            VStack(alignment: .leading, spacing: 4) {82                TextField("Recipient address (0x…)", text: $recipientInput)83                    .textFieldStyle(.roundedBorder)84                    .font(.body.monospaced())85                    .autocorrectionDisabled()86                switch recipientVerdict {87                case .valid:88                    Label("Checksum valid", systemImage: "checkmark.circle")89                        .font(.caption).foregroundStyle(.green)90                case .validNoChecksum:91                    Label("Address has no checksum (all one case). Double-check it before sending.",92                          systemImage: "exclamationmark.triangle")93                        .font(.caption).foregroundStyle(.orange)94                case .invalid:95                    if !recipientInput.isEmpty {96                        Label("Not a valid address", systemImage: "xmark.circle")97                            .font(.caption).foregroundStyle(.red)98                    }99                }100            }101102            VStack(alignment: .leading, spacing: 4) {103                HStack {104                    TextField("Amount", text: $amountInput)105                        .textFieldStyle(.roundedBorder)106                        .font(.body.monospaced())107                    Text(asset.symbol(on: app.network)).foregroundStyle(.secondary)108                    Button("Max") {109                        amountInput = TokenAmount.format(assetBalance, decimals: asset.decimals)110                    }111                    .controlSize(.small)112                }113                Text("Balance: \(TokenAmount.format(assetBalance, decimals: asset.decimals)) \(asset.symbol(on: app.network))")114                    .font(.caption)115                    .foregroundStyle(.secondary)116            }117118            if let errorMessage {119                Text(errorMessage).foregroundStyle(.red).font(.callout)120            }121122            HStack {123                Button("Cancel") { dismiss() }124                Spacer()125                if case .preparing = stage {126                    ProgressView().controlSize(.small)127                }128                Button("Review") { prepare() }129                    .buttonStyle(.borderedProminent)130                    .disabled(!formValid || isPreparing)131            }132        }133        .onAppear { resetAssetIfUnavailable() }134        .onChange(of: app.network) { _, _ in resetAssetIfUnavailable(force: true) }135    }136137    private func resetAssetIfUnavailable(force: Bool = false) {138        let available = Asset.available(on: app.network)139        if force || !available.contains(asset) {140            asset = available.first ?? .native141        }142    }143144    private var assetBalance: BigUInt {145        switch asset {146        case .native: return app.balances.ethWei147        case .token(let token): return app.balances.tokenUnits[token.symbol] ?? 0148        }149    }150151    private var isPreparing: Bool {152        if case .preparing = stage { return true }153        return false154    }155156    private var formValid: Bool {157        recipientVerdict != .invalid158            && TokenAmount.parse(amountInput, decimals: asset.decimals).map { $0 > 0 } == true159    }160161    private func prepare() {162        guard let from = app.address,163              let amount = TokenAmount.parse(amountInput, decimals: asset.decimals) else { return }164        let checksummedRecipient: String165        switch recipientVerdict {166        case .valid(let c), .validNoChecksum(let c): checksummedRecipient = c167        case .invalid: return168        }169        errorMessage = nil170        stage = .preparing171        let network = app.network172        let rpc = app.rpc()173        let balances = app.balances174        let selectedAsset = asset175        Task {176            do {177                let prepared = try await TransactionService.prepare(178                    asset: selectedAsset, network: network, from: from,179                    recipient: checksummedRecipient, amountUnits: amount,180                    rpc: rpc, balances: balances181                )182                stage = .confirm(prepared)183            } catch {184                errorMessage = error.localizedDescription185                stage = .form186            }187        }188    }189190    // MARK: - Confirm191192    private func confirmView(_ prepared: TransactionService.PreparedTransfer) -> some View {193        VStack(alignment: .leading, spacing: 14) {194            Text("Confirm transaction").font(.headline)195196            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {197                GridRow {198                    Text("Recipient").foregroundStyle(.secondary)199                    Text(prepared.recipient).font(.callout.monospaced()).textSelection(.enabled)200                }201                GridRow {202                    Text("Amount").foregroundStyle(.secondary)203                    Text("\(TokenAmount.format(prepared.amountUnits, decimals: prepared.asset.decimals)) \(prepared.asset.symbol(on: prepared.network))")204                        .fontWeight(.semibold)205                }206                GridRow {207                    Text("Network").foregroundStyle(.secondary)208                    Text("\(prepared.network.config.displayName) (chain \(String(prepared.network.config.chainID)))")209                }210                GridRow {211                    Text("Max gas").foregroundStyle(.secondary)212                    Text("\(TokenAmount.formatWei(prepared.maxGasCostWei)) \(prepared.network.config.nativeSymbol)" + (prepared.l1DataFee > 0 ? " (incl. L1 data fee)" : ""))213                }214                GridRow {215                    Text("Total").foregroundStyle(.secondary)216                    totalText(prepared)217                }218            }219            .font(.callout)220221            Divider()222            Text("Enter your vault password to sign.")223                .font(.callout)224                .foregroundStyle(.secondary)225            SecureField("Vault password", text: $password)226                .textFieldStyle(.roundedBorder)227            if let errorMessage {228                Text(errorMessage).foregroundStyle(.red).font(.callout)229            }230            HStack {231                Button("Back") {232                    password = ""233                    errorMessage = nil234                    stage = .form235                }236                Spacer()237                Button("Sign & send") { send(prepared) }238                    .buttonStyle(.borderedProminent)239                    .disabled(password.isEmpty)240            }241        }242    }243244    private func totalText(_ prepared: TransactionService.PreparedTransfer) -> Text {245        let native = prepared.network.config.nativeSymbol246        switch prepared.asset {247        case .native:248            let total = prepared.amountUnits + prepared.maxGasCostWei249            return Text("≤ \(TokenAmount.formatWei(total)) \(native) (amount + max gas)")250        case .token(let token):251            return Text("\(TokenAmount.format(prepared.amountUnits, decimals: token.decimals)) \(token.symbol) + gas in \(native)")252        }253    }254255    private func send(_ prepared: TransactionService.PreparedTransfer) {256        errorMessage = nil257        stage = .sending258        let manager = app.keyManager259        let candidate = password260        let rpc = app.rpc()261        Task {262            do {263                // Unlock off the main thread (slow KDF), sign, discard the key.264                let wallet = try await Task.detached {265                    try manager.unlock(password: candidate)266                }.value267                let hash = try await TransactionService.send(prepared, privateKey: wallet.privateKey, rpc: rpc)268                password = ""269                app.recordSend(TransactionRecord(270                    hash: hash, asset: prepared.asset, amountUnits: prepared.amountUnits,271                    recipient: prepared.recipient, network: prepared.network, status: .pending272                ))273                stage = .sent(hash: hash)274                trackReceipt(hash: hash, rpc: rpc)275            } catch {276                errorMessage = error.localizedDescription277                stage = .confirm(prepared)278            }279        }280    }281282    private func trackReceipt(hash: String, rpc: RPCService) {283        Task {284            let outcome = await TransactionService.waitForReceipt(hash: hash, rpc: rpc)285            switch outcome {286            case .confirmed:287                app.updateRecord(hash: hash, status: .confirmed)288            case .failed:289                app.updateRecord(hash: hash, status: .failed)290            case .timedOut:291                break   // stays pending; explorer link tells the full story292            }293            await app.refreshBalances()294        }295    }296297    // MARK: - Sent298299    private func sentView(_ hash: String) -> some View {300        VStack(spacing: 14) {301            Image(systemName: "paperplane.circle.fill")302                .font(.system(size: 40))303                .foregroundStyle(.green)304            Text("Transaction broadcast").font(.headline)305            Text(hash)306                .font(.caption.monospaced())307                .textSelection(.enabled)308                .lineLimit(1)309                .truncationMode(.middle)310            Text("Status will update in Activity once the network confirms it (usually a few seconds on Base).")311                .font(.callout)312                .foregroundStyle(.secondary)313                .multilineTextAlignment(.center)314            HStack {315                Link("View on BaseScan", destination: app.network.explorerTxURL(hash))316                Spacer()317                Button("Done") { dismiss() }318                    .buttonStyle(.borderedProminent)319                    .keyboardShortcut(.defaultAction)320            }321        }322        .frame(maxWidth: .infinity)323    }324}325