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%
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