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%
14.2 KB · 374 lines swift
Raw Blame History
1//2//  BitcoinView.swift3//  OS Vault4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI10import BigInt1112/// Bitcoin panel: balance (confirmed/pending), receive with address rotation,13/// send with live fee presets from mempool.space, and the mainnet/signet14/// switch. Same security flow as EVM sends: password → sign → discard.15struct BitcoinView: View {16    @EnvironmentObject var app: AppState17    @Environment(\.dismiss) private var dismiss1819    enum Mode { case overview, receive, send }20    @State private var mode: Mode = .overview2122    // Receive23    @State private var receiveAddress: String?2425    // Send26    @State private var recipient = ""27    @State private var amountInput = ""28    @State private var fees: BitcoinService.FeeRates?29    @State private var selectedRate: UInt64 = 230    @State private var prepared: BitcoinService.PreparedBTCSend?31    @State private var password = ""32    @State private var sending = false33    @State private var sentTxid: String?34    @State private var errorMessage: String?3536    // Network switch37    @State private var switchPassword = ""38    @State private var showNetworkSwitch = false3940    var body: some View {41        VStack(alignment: .leading, spacing: 16) {42            header43            switch mode {44            case .overview: overview45            case .receive: receive46            case .send: send47            }48        }49        .padding(24)50        .frame(width: 470)51        .onAppear {52            Task { fees = await app.bitcoinService.recommendedFees()53                   selectedRate = fees?.halfHour ?? 2 }54        }55    }5657    private var header: some View {58        HStack {59            Text("Bitcoin").font(.title2.bold())60            Spacer()61            Text(app.btcNetwork.isTestnet ? "SIGNET · test" : "MAINNET")62                .font(.caption.weight(.bold))63                .padding(.horizontal, 10).padding(.vertical, 4)64                .background(app.btcNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.yellow.opacity(0.25))65                .foregroundStyle(app.btcNetwork.isTestnet ? Color.orange : Color.yellow)66                .clipShape(Capsule())67            Button("Done") { dismiss() }68        }69    }7071    // MARK: - Overview7273    private var overview: some View {74        VStack(alignment: .leading, spacing: 14) {75            VStack(alignment: .leading, spacing: 8) {76                HStack(alignment: .firstTextBaseline) {77                    Text("BTC").font(.headline)78                    Spacer()79                    VStack(alignment: .trailing, spacing: 2) {80                        Text(BitcoinService.formatBTC(app.btcBalance?.totalSats ?? 0) + " BTC")81                            .font(.system(size: 26, weight: .bold, design: .rounded))82                            .monospacedDigit()83                        if let fiat = btcFiat {84                            Text(fiat).font(.caption).foregroundStyle(.secondary)85                        }86                    }87                }88                if let balance = app.btcBalance, balance.pendingSats > 0 {89                    Text("\(BitcoinService.formatBTC(balance.confirmedSats)) confirmed + \(BitcoinService.formatBTC(balance.pendingSats)) pending")90                        .font(.caption).foregroundStyle(.orange)91                }92                if app.btcSyncing {93                    HStack { ProgressView().controlSize(.small); Text("Syncing…").font(.caption).foregroundStyle(.secondary) }94                }95                if let error = app.btcError {96                    Label(error, systemImage: "wifi.exclamationmark")97                        .font(.caption).foregroundStyle(.orange)98                }99            }100            .padding(14)101            .background(.quaternary.opacity(0.4))102            .clipShape(RoundedRectangle(cornerRadius: 10))103104            HStack(spacing: 12) {105                Button {106                    mode = .send107                } label: {108                    Label("Send", systemImage: "arrow.up.circle.fill").frame(maxWidth: .infinity)109                }110                .buttonStyle(.borderedProminent)111                Button {112                    mode = .receive113                    receiveAddress = nil114                } label: {115                    Label("Receive", systemImage: "arrow.down.circle").frame(maxWidth: .infinity)116                }117                Button {118                    Task { await app.refreshBitcoin() }119                } label: {120                    Image(systemName: "arrow.clockwise")121                }122                .help("Sync now")123            }124125            Text("Native SegWit (BIP-84) from your existing recovery phrase. Sync, fees and broadcast via mempool.space — keyless, with automatic fallback.")126                .font(.caption)127                .foregroundStyle(.secondary)128129            DisclosureGroup("Network", isExpanded: $showNetworkSwitch) {130                VStack(alignment: .leading, spacing: 8) {131                    Text("Switching re-derives your Bitcoin wallet on the other network. Enter your vault password to apply.")132                        .font(.caption).foregroundStyle(.secondary)133                    HStack {134                        SecureField("Vault password", text: $switchPassword)135                            .textFieldStyle(.roundedBorder)136                        Button(app.btcNetwork == .signet ? "Switch to mainnet" : "Switch to signet") {137                            switchNetwork()138                        }139                        .disabled(switchPassword.isEmpty)140                    }141                }142                .padding(.top, 6)143            }144            .font(.callout)145        }146    }147148    private var btcFiat: String? {149        guard app.pricesEnabled, let sats = app.btcBalance?.totalSats, sats > 0,150              let price = app.fiatPrices["BTC"] else { return nil }151        let value = PriceService.fiatValue(units: BigUInt(sats), decimals: 8, price: price)152        return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency)153    }154155    private func switchNetwork() {156        let target: BitcoinService.BTCNetwork = app.btcNetwork == .signet ? .mainnet : .signet157        let candidate = switchPassword158        errorMessage = nil159        Task {160            do {161                try await app.switchBitcoinNetwork(to: target, password: candidate)162                switchPassword = ""163                showNetworkSwitch = false164            } catch {165                errorMessage = error.localizedDescription166            }167        }168    }169170    // MARK: - Receive171172    private var receive: some View {173        VStack(spacing: 14) {174            if let address = receiveAddress {175                if let qr = QRCode.image(for: address) {176                    Image(nsImage: qr)177                        .interpolation(.none)178                        .resizable()179                        .frame(width: 200, height: 200)180                        .background(.white)181                        .clipShape(RoundedRectangle(cornerRadius: 8))182                }183                Text(address)184                    .font(.callout.monospaced())185                    .textSelection(.enabled)186                    .padding(8)187                    .background(.quaternary.opacity(0.4))188                    .clipShape(RoundedRectangle(cornerRadius: 8))189                CopyButton(text: address)190                Text("A fresh address is revealed on every use (privacy). Older addresses keep working.")191                    .font(.caption).foregroundStyle(.secondary)192                    .multilineTextAlignment(.center)193            } else {194                ProgressView()195            }196            if let errorMessage {197                Text(errorMessage).foregroundStyle(.red).font(.callout)198            }199            Button("Back") { mode = .overview }200        }201        .frame(maxWidth: .infinity)202        .onAppear {203            Task {204                do { receiveAddress = try await app.bitcoinService.receiveAddress() }205                catch { errorMessage = error.localizedDescription }206            }207        }208    }209210    // MARK: - Send211212    private var send: some View {213        VStack(alignment: .leading, spacing: 12) {214            if let txid = sentTxid {215                sentView(txid)216            } else if let prepared {217                confirmView(prepared)218            } else {219                sendForm220            }221        }222    }223224    private var sendForm: some View {225        VStack(alignment: .leading, spacing: 12) {226            TextField("Recipient (bc1q… / tb1q…)", text: $recipient)227                .textFieldStyle(.roundedBorder)228                .font(.body.monospaced())229                .autocorrectionDisabled()230            if !recipient.isEmpty && !BitcoinService.validate(address: recipient, network: app.btcNetwork) {231                Label("Not a valid \(app.btcNetwork.displayName) address", systemImage: "xmark.circle")232                    .font(.caption).foregroundStyle(.red)233            }234            HStack {235                TextField("Amount", text: $amountInput)236                    .textFieldStyle(.roundedBorder)237                    .font(.body.monospaced())238                Text("BTC").foregroundStyle(.secondary)239            }240            Text("Balance: \(BitcoinService.formatBTC(app.btcBalance?.totalSats ?? 0)) BTC")241                .font(.caption).foregroundStyle(.secondary)242243            if let fees {244                Picker("Fee", selection: $selectedRate) {245                    Text("Fast (~10 min) · \(fees.fastest) sat/vB").tag(fees.fastest)246                    Text("Normal (~30 min) · \(fees.halfHour) sat/vB").tag(fees.halfHour)247                    Text("Slow (~1 h) · \(fees.hour) sat/vB").tag(fees.hour)248                    Text("Economy · \(fees.economy) sat/vB").tag(fees.economy)249                }250            }251            if let errorMessage {252                Text(errorMessage).foregroundStyle(.red).font(.callout)253            }254            HStack {255                Button("Back") { mode = .overview; errorMessage = nil }256                Spacer()257                Button("Review") { prepare() }258                    .buttonStyle(.borderedProminent)259                    .disabled(!sendFormValid)260            }261        }262    }263264    private var sendFormValid: Bool {265        BitcoinService.validate(address: recipient, network: app.btcNetwork)266            && (BitcoinService.parseBTC(amountInput) ?? 0) > 0267    }268269    private func prepare() {270        guard let sats = BitcoinService.parseBTC(amountInput) else { return }271        errorMessage = nil272        let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines)273        let rate = selectedRate274        Task {275            do {276                prepared = try await app.bitcoinService.prepareSend(277                    to: to, amountSats: sats, feeRateSatVb: rate)278            } catch {279                errorMessage = error.localizedDescription280            }281        }282    }283284    private func confirmView(_ p: BitcoinService.PreparedBTCSend) -> some View {285        VStack(alignment: .leading, spacing: 12) {286            Text("Confirm transaction").font(.headline)287            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {288                GridRow {289                    Text("Recipient").foregroundStyle(.secondary)290                    Text(p.recipient).font(.callout.monospaced()).textSelection(.enabled)291                }292                GridRow {293                    Text("Amount").foregroundStyle(.secondary)294                    Text("\(BitcoinService.formatBTC(p.amountSats)) BTC").fontWeight(.semibold)295                }296                GridRow {297                    Text("Network").foregroundStyle(.secondary)298                    Text(p.network.displayName)299                }300                GridRow {301                    Text("Fee").foregroundStyle(.secondary)302                    Text("\(BitcoinService.formatBTC(p.feeSats)) BTC (\(p.feeRateSatVb) sat/vB)")303                }304                GridRow {305                    Text("Total").foregroundStyle(.secondary)306                    Text("\(BitcoinService.formatBTC(p.amountSats + p.feeSats)) BTC")307                }308            }309            .font(.callout)310            Divider()311            SecureField("Vault password to sign", text: $password)312                .textFieldStyle(.roundedBorder)313            if let errorMessage {314                Text(errorMessage).foregroundStyle(.red).font(.callout)315            }316            HStack {317                Button("Back") {318                    prepared = nil319                    password = ""320                    Task { await app.bitcoinService.cancelPending() }321                }322                Spacer()323                if sending { ProgressView().controlSize(.small) }324                Button("Sign & send") { broadcast() }325                    .buttonStyle(.borderedProminent)326                    .disabled(password.isEmpty || sending)327            }328        }329    }330331    private func broadcast() {332        sending = true333        errorMessage = nil334        let candidate = password335        let manager = app.keyManager336        Task {337            do {338                let mnemonic = try await Task.detached {339                    try manager.unlock(password: candidate).mnemonic340                }.value341                let txid = try await app.bitcoinService.signAndBroadcast(mnemonic: mnemonic)342                password = ""343                sentTxid = txid344                await app.refreshBitcoin()345            } catch {346                errorMessage = error.localizedDescription347            }348            sending = false349        }350    }351352    private func sentView(_ txid: String) -> some View {353        VStack(spacing: 12) {354            Image(systemName: "paperplane.circle.fill")355                .font(.system(size: 38)).foregroundStyle(.green)356            Text("Transaction broadcast").font(.headline)357            Text(txid)358                .font(.caption.monospaced())359                .textSelection(.enabled)360                .lineLimit(1).truncationMode(.middle)361            Link("View on mempool.space", destination: app.btcNetwork.explorerTxURL(txid))362            Button("Done") {363                sentTxid = nil364                prepared = nil365                recipient = ""366                amountInput = ""367                mode = .overview368            }369            .buttonStyle(.borderedProminent)370        }371        .frame(maxWidth: .infinity)372    }373}374