// // SendView.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI import BigInt /// Send flow: token + recipient + amount → prepare (gas preview) → /// confirmation screen showing recipient, amount, network, gas and total → /// password re-entry unlocks the vault, signs, broadcasts, tracks the receipt. struct SendView: View { @EnvironmentObject var app: AppState @Environment(\.dismiss) private var dismiss enum Stage { case form case preparing case confirm(TransactionService.PreparedTransfer) case sending case sent(hash: String) } @State private var stage: Stage = .form @State private var asset: Asset = .token(.usdc) @State private var recipientInput = "" @State private var amountInput = "" @State private var password = "" @State private var errorMessage: String? var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("Send").font(.title2.bold()) Spacer() NetworkBadge(network: app.network) } content } .padding(24) .frame(width: 460) } @ViewBuilder private var content: some View { switch stage { case .form, .preparing: form case .confirm(let prepared): confirmView(prepared) case .sending: HStack { ProgressView().controlSize(.small) Text("Signing and broadcasting…") } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 30) case .sent(let hash): sentView(hash) } } // MARK: - Form private var recipientVerdict: AddressValidator.Verdict { AddressValidator.validate(recipientInput) } private var form: some View { VStack(alignment: .leading, spacing: 14) { Picker("Asset", selection: $asset) { ForEach(Asset.available(on: app.network)) { candidate in Text("\(candidate.symbol(on: app.network)) — \(candidate.name(on: app.network))") .tag(candidate) } } VStack(alignment: .leading, spacing: 4) { TextField("Recipient address (0x…)", text: $recipientInput) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) .autocorrectionDisabled() switch recipientVerdict { case .valid: Label("Checksum valid", systemImage: "checkmark.circle") .font(.caption).foregroundStyle(.green) case .validNoChecksum: Label("Address has no checksum (all one case). Double-check it before sending.", systemImage: "exclamationmark.triangle") .font(.caption).foregroundStyle(.orange) case .invalid: if !recipientInput.isEmpty { Label("Not a valid address", systemImage: "xmark.circle") .font(.caption).foregroundStyle(.red) } } } VStack(alignment: .leading, spacing: 4) { HStack { TextField("Amount", text: $amountInput) .textFieldStyle(.roundedBorder) .font(.body.monospaced()) Text(asset.symbol(on: app.network)).foregroundStyle(.secondary) Button("Max") { amountInput = TokenAmount.format(assetBalance, decimals: asset.decimals) } .controlSize(.small) } Text("Balance: \(TokenAmount.format(assetBalance, decimals: asset.decimals)) \(asset.symbol(on: app.network))") .font(.caption) .foregroundStyle(.secondary) } if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Cancel") { dismiss() } Spacer() if case .preparing = stage { ProgressView().controlSize(.small) } Button("Review") { prepare() } .buttonStyle(.borderedProminent) .disabled(!formValid || isPreparing) } } .onAppear { resetAssetIfUnavailable() } .onChange(of: app.network) { _, _ in resetAssetIfUnavailable(force: true) } } private func resetAssetIfUnavailable(force: Bool = false) { let available = Asset.available(on: app.network) if force || !available.contains(asset) { asset = available.first ?? .native } } private var assetBalance: BigUInt { switch asset { case .native: return app.balances.ethWei case .token(let token): return app.balances.tokenUnits[token.symbol] ?? 0 } } private var isPreparing: Bool { if case .preparing = stage { return true } return false } private var formValid: Bool { recipientVerdict != .invalid && TokenAmount.parse(amountInput, decimals: asset.decimals).map { $0 > 0 } == true } private func prepare() { guard let from = app.address, let amount = TokenAmount.parse(amountInput, decimals: asset.decimals) else { return } let checksummedRecipient: String switch recipientVerdict { case .valid(let c), .validNoChecksum(let c): checksummedRecipient = c case .invalid: return } errorMessage = nil stage = .preparing let network = app.network let rpc = app.rpc() let balances = app.balances let selectedAsset = asset Task { do { let prepared = try await TransactionService.prepare( asset: selectedAsset, network: network, from: from, recipient: checksummedRecipient, amountUnits: amount, rpc: rpc, balances: balances ) stage = .confirm(prepared) } catch { errorMessage = error.localizedDescription stage = .form } } } // MARK: - Confirm private func confirmView(_ prepared: TransactionService.PreparedTransfer) -> some View { VStack(alignment: .leading, spacing: 14) { Text("Confirm transaction").font(.headline) Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("Recipient").foregroundStyle(.secondary) Text(prepared.recipient).font(.callout.monospaced()).textSelection(.enabled) } GridRow { Text("Amount").foregroundStyle(.secondary) Text("\(TokenAmount.format(prepared.amountUnits, decimals: prepared.asset.decimals)) \(prepared.asset.symbol(on: prepared.network))") .fontWeight(.semibold) } GridRow { Text("Network").foregroundStyle(.secondary) Text("\(prepared.network.config.displayName) (chain \(String(prepared.network.config.chainID)))") } GridRow { Text("Max gas").foregroundStyle(.secondary) Text("\(TokenAmount.formatWei(prepared.maxGasCostWei)) \(prepared.network.config.nativeSymbol)" + (prepared.l1DataFee > 0 ? " (incl. L1 data fee)" : "")) } GridRow { Text("Total").foregroundStyle(.secondary) totalText(prepared) } } .font(.callout) Divider() Text("Enter your vault password to sign.") .font(.callout) .foregroundStyle(.secondary) SecureField("Vault password", text: $password) .textFieldStyle(.roundedBorder) if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.callout) } HStack { Button("Back") { password = "" errorMessage = nil stage = .form } Spacer() Button("Sign & send") { send(prepared) } .buttonStyle(.borderedProminent) .disabled(password.isEmpty) } } } private func totalText(_ prepared: TransactionService.PreparedTransfer) -> Text { let native = prepared.network.config.nativeSymbol switch prepared.asset { case .native: let total = prepared.amountUnits + prepared.maxGasCostWei return Text("≤ \(TokenAmount.formatWei(total)) \(native) (amount + max gas)") case .token(let token): return Text("\(TokenAmount.format(prepared.amountUnits, decimals: token.decimals)) \(token.symbol) + gas in \(native)") } } private func send(_ prepared: TransactionService.PreparedTransfer) { errorMessage = nil stage = .sending let manager = app.keyManager let candidate = password let rpc = app.rpc() Task { do { // Unlock off the main thread (slow KDF), sign, discard the key. let wallet = try await Task.detached { try manager.unlock(password: candidate) }.value let hash = try await TransactionService.send(prepared, privateKey: wallet.privateKey, rpc: rpc) password = "" app.recordSend(TransactionRecord( hash: hash, asset: prepared.asset, amountUnits: prepared.amountUnits, recipient: prepared.recipient, network: prepared.network, status: .pending )) stage = .sent(hash: hash) trackReceipt(hash: hash, rpc: rpc) } catch { errorMessage = error.localizedDescription stage = .confirm(prepared) } } } private func trackReceipt(hash: String, rpc: RPCService) { Task { let outcome = await TransactionService.waitForReceipt(hash: hash, rpc: rpc) switch outcome { case .confirmed: app.updateRecord(hash: hash, status: .confirmed) case .failed: app.updateRecord(hash: hash, status: .failed) case .timedOut: break // stays pending; explorer link tells the full story } await app.refreshBalances() } } // MARK: - Sent private func sentView(_ hash: String) -> some View { VStack(spacing: 14) { Image(systemName: "paperplane.circle.fill") .font(.system(size: 40)) .foregroundStyle(.green) Text("Transaction broadcast").font(.headline) Text(hash) .font(.caption.monospaced()) .textSelection(.enabled) .lineLimit(1) .truncationMode(.middle) Text("Status will update in Activity once the network confirms it (usually a few seconds on Base).") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) HStack { Link("View on BaseScan", destination: app.network.explorerTxURL(hash)) Spacer() Button("Done") { dismiss() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } } .frame(maxWidth: .infinity) } }