// // BitcoinView.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI import BigInt /// Bitcoin panel: balance (confirmed/pending), receive with address rotation, /// send with live fee presets from mempool.space, and the mainnet/signet /// switch. Same security flow as EVM sends: password → sign → discard. struct BitcoinView: View { @EnvironmentObject var app: AppState @Environment(\.dismiss) private var dismiss enum Mode { case overview, receive, send } @State private var mode: Mode = .overview // Receive @State private var receiveAddress: String? // Send @State private var recipient = "" @State private var amountInput = "" @State private var fees: BitcoinService.FeeRates? @State private var selectedRate: UInt64 = 2 @State private var prepared: BitcoinService.PreparedBTCSend? @State private var password = "" @State private var sending = false @State private var sentTxid: String? @State private var errorMessage: String? // Network switch @State private var switchPassword = "" @State private var showNetworkSwitch = false var body: some View { VStack(alignment: .leading, spacing: 16) { header switch mode { case .overview: overview case .receive: receive case .send: send } } .padding(24) .frame(width: 470) .onAppear { Task { fees = await app.bitcoinService.recommendedFees() selectedRate = fees?.halfHour ?? 2 } } } private var header: some View { HStack { Text("Bitcoin").font(.title2.bold()) Spacer() Text(app.btcNetwork.isTestnet ? "SIGNET · test" : "MAINNET") .font(.caption.weight(.bold)) .padding(.horizontal, 10).padding(.vertical, 4) .background(app.btcNetwork.isTestnet ? Color.orange.opacity(0.25) : Color.yellow.opacity(0.25)) .foregroundStyle(app.btcNetwork.isTestnet ? Color.orange : Color.yellow) .clipShape(Capsule()) Button("Done") { dismiss() } } } // MARK: - Overview private var overview: some View { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { Text("BTC").font(.headline) Spacer() VStack(alignment: .trailing, spacing: 2) { Text(BitcoinService.formatBTC(app.btcBalance?.totalSats ?? 0) + " BTC") .font(.system(size: 26, weight: .bold, design: .rounded)) .monospacedDigit() if let fiat = btcFiat { Text(fiat).font(.caption).foregroundStyle(.secondary) } } } if let balance = app.btcBalance, balance.pendingSats > 0 { Text("\(BitcoinService.formatBTC(balance.confirmedSats)) confirmed + \(BitcoinService.formatBTC(balance.pendingSats)) pending") .font(.caption).foregroundStyle(.orange) } if app.btcSyncing { HStack { ProgressView().controlSize(.small); Text("Syncing…").font(.caption).foregroundStyle(.secondary) } } if let error = app.btcError { Label(error, systemImage: "wifi.exclamationmark") .font(.caption).foregroundStyle(.orange) } } .padding(14) .background(.quaternary.opacity(0.4)) .clipShape(RoundedRectangle(cornerRadius: 10)) HStack(spacing: 12) { Button { mode = .send } label: { Label("Send", systemImage: "arrow.up.circle.fill").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button { mode = .receive receiveAddress = nil } label: { Label("Receive", systemImage: "arrow.down.circle").frame(maxWidth: .infinity) } Button { Task { await app.refreshBitcoin() } } label: { Image(systemName: "arrow.clockwise") } .help("Sync now") } Text("Native SegWit (BIP-84) from your existing recovery phrase. Sync, fees and broadcast via mempool.space — keyless, with automatic fallback.") .font(.caption) .foregroundStyle(.secondary) DisclosureGroup("Network", isExpanded: $showNetworkSwitch) { VStack(alignment: .leading, spacing: 8) { Text("Switching re-derives your Bitcoin wallet on the other network. Enter your vault password to apply.") .font(.caption).foregroundStyle(.secondary) HStack { SecureField("Vault password", text: $switchPassword) .textFieldStyle(.roundedBorder) Button(app.btcNetwork == .signet ? "Switch to mainnet" : "Switch to signet") { switchNetwork() } .disabled(switchPassword.isEmpty) } } .padding(.top, 6) } .font(.callout) } } private var btcFiat: String? { guard app.pricesEnabled, let sats = app.btcBalance?.totalSats, sats > 0, let price = app.fiatPrices["BTC"] else { return nil } let value = PriceService.fiatValue(units: BigUInt(sats), decimals: 8, price: price) return "≈ " + PriceService.formatFiat(value, currency: app.fiatCurrency) } private func switchNetwork() { let target: BitcoinService.BTCNetwork = app.btcNetwork == .signet ? .mainnet : .signet let candidate = switchPassword errorMessage = nil Task { do { try await app.switchBitcoinNetwork(to: target, password: candidate) switchPassword = "" showNetworkSwitch = false } catch { errorMessage = error.localizedDescription } } } // MARK: - Receive private var receive: some View { VStack(spacing: 14) { if let address = receiveAddress { if let qr = QRCode.image(for: address) { Image(nsImage: qr) .interpolation(.none) .resizable() .frame(width: 200, height: 200) .background(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) } Text(address) .font(.callout.monospaced()) .textSelection(.enabled) .padding(8) .background(.quaternary.opacity(0.4)) .clipShape(RoundedRectangle(cornerRadius: 8)) CopyButton(text: address) Text("A fresh address is revealed on every use (privacy). Older addresses keep working.") .font(.caption).foregroundStyle(.secondary) .multilineTextAlignment(.center) } else { ProgressView() } if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } Button("Back") { mode = .overview } } .frame(maxWidth: .infinity) .onAppear { Task { do { receiveAddress = try await app.bitcoinService.receiveAddress() } catch { errorMessage = error.localizedDescription } } } } // MARK: - Send private var send: some View { VStack(alignment: .leading, spacing: 12) { if let txid = sentTxid { sentView(txid) } else if let prepared { confirmView(prepared) } else { sendForm } } } private var sendForm: some View { VStack(alignment: .leading, spacing: 12) { TextField("Recipient (bc1q… / tb1q…)", text: $recipient) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) .autocorrectionDisabled() if !recipient.isEmpty && !BitcoinService.validate(address: recipient, network: app.btcNetwork) { Label("Not a valid \(app.btcNetwork.displayName) address", systemImage: "xmark.circle") .font(.caption).foregroundStyle(.red) } HStack { TextField("Amount", text: $amountInput) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) Text("BTC").foregroundStyle(.secondary) } Text("Balance: \(BitcoinService.formatBTC(app.btcBalance?.totalSats ?? 0)) BTC") .font(.caption).foregroundStyle(.secondary) if let fees { Picker("Fee", selection: $selectedRate) { Text("Fast (~10 min) · \(fees.fastest) sat/vB").tag(fees.fastest) Text("Normal (~30 min) · \(fees.halfHour) sat/vB").tag(fees.halfHour) Text("Slow (~1 h) · \(fees.hour) sat/vB").tag(fees.hour) Text("Economy · \(fees.economy) sat/vB").tag(fees.economy) } } if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { mode = .overview; errorMessage = nil } Spacer() Button("Review") { prepare() } .buttonStyle(.borderedProminent) .disabled(!sendFormValid) } } } private var sendFormValid: Bool { BitcoinService.validate(address: recipient, network: app.btcNetwork) && (BitcoinService.parseBTC(amountInput) ?? 0) > 0 } private func prepare() { guard let sats = BitcoinService.parseBTC(amountInput) else { return } errorMessage = nil let to = recipient.trimmingCharacters(in: .whitespacesAndNewlines) let rate = selectedRate Task { do { prepared = try await app.bitcoinService.prepareSend( to: to, amountSats: sats, feeRateSatVb: rate) } catch { errorMessage = error.localizedDescription } } } private func confirmView(_ p: BitcoinService.PreparedBTCSend) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Confirm transaction").font(.headline) Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("Recipient").foregroundStyle(.secondary) Text(p.recipient).font(.callout.monospaced()).textSelection(.enabled) } GridRow { Text("Amount").foregroundStyle(.secondary) Text("\(BitcoinService.formatBTC(p.amountSats)) BTC").fontWeight(.semibold) } GridRow { Text("Network").foregroundStyle(.secondary) Text(p.network.displayName) } GridRow { Text("Fee").foregroundStyle(.secondary) Text("\(BitcoinService.formatBTC(p.feeSats)) BTC (\(p.feeRateSatVb) sat/vB)") } GridRow { Text("Total").foregroundStyle(.secondary) Text("\(BitcoinService.formatBTC(p.amountSats + p.feeSats)) BTC") } } .font(.callout) Divider() SecureField("Vault password to sign", text: $password) .textFieldStyle(.roundedBorder) if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { prepared = nil password = "" Task { await app.bitcoinService.cancelPending() } } Spacer() if sending { ProgressView().controlSize(.small) } Button("Sign & send") { broadcast() } .buttonStyle(.borderedProminent) .disabled(password.isEmpty || sending) } } } private func broadcast() { sending = true errorMessage = nil let candidate = password let manager = app.keyManager Task { do { let mnemonic = try await Task.detached { try manager.unlock(password: candidate).mnemonic }.value let txid = try await app.bitcoinService.signAndBroadcast(mnemonic: mnemonic) password = "" sentTxid = txid await app.refreshBitcoin() } catch { errorMessage = error.localizedDescription } sending = false } } private func sentView(_ txid: String) -> some View { VStack(spacing: 12) { Image(systemName: "paperplane.circle.fill") .font(.system(size: 38)).foregroundStyle(.green) Text("Transaction broadcast").font(.headline) Text(txid) .font(.caption.monospaced()) .textSelection(.enabled) .lineLimit(1).truncationMode(.middle) Link("View on mempool.space", destination: app.btcNetwork.explorerTxURL(txid)) Button("Done") { sentTxid = nil prepared = nil recipient = "" amountInput = "" mode = .overview } .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity) } }