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