hellbender-wallet/birch/ViewModels/SendViewModel.swift
Nick Klockenga 209750c4e5
Rebrand Wallet to Birch Wallet (#28)
* step one

* progress

* minor theme enhancements

* update screenshot and icon links in README.md

* update site link

* swiftformat fixes
2026-04-30 21:00:59 -04:00

709 lines
21 KiB
Swift

import Foundation
import Observation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "birch", category: "SendViewModel")
@Observable
@MainActor
final class SendViewModel: PSBTFlowManaging {
enum Step: Int, CaseIterable {
case recipients
case review
case psbtDisplay
case psbtScan
case broadcast
}
/// Navigation
var currentStep: Step = .recipients
// Input
var recipients: [Recipient] = [Recipient()]
var amountInFiat: Bool = false
var fiatDisplayAmount: [UUID: String] = [:] // per-recipient fiat display strings
var feeRateSatVb: String = "" // empty until rates load
var selectedFeePreset: FeePreset = .medium
var showAddressScanner: Bool = false
var scanTargetRecipientIndex: Int = 0
var focusAmountIndex: Int?
var recommendedFees: BitcoinService.RecommendedFees?
// UTXO selection
var manualUTXOSelection: Bool = false
var selectedUTXOIds: Set<String> = [] // "txid:vout"
var showUTXOPicker: Bool = false
/// Validation
var showValidationErrors: Bool = false
// State
var psbtBase64: String = ""
var psbtBytes: Data = .init()
var signaturesCollected: Int = 0
var requiredSignatures: Int = 2
var totalCosigners: Int = 1
var broadcastTxid: String = ""
var finalizedTxBytes: Data = .init()
var errorMessage: String?
var isProcessing: Bool = false
var showExportQR: Bool = false
// Saved PSBT
var savedPSBTId: UUID?
var savedPSBTName: String = ""
var showSavePSBT: Bool = false
var showSavedConfirmation: Bool = false
var showLoadPSBT: Bool = false
// Import PSBT
var showImportPSBTQR: Bool = false
var showImportPSBTFile: Bool = false
/// Cosigner signing status (populated from PSBT analysis)
var signerStatus: [(label: String, fingerprint: String, hasSigned: Bool)] = []
// Transaction details (populated after PSBT creation)
var totalFee: UInt64 = 0
var changeAmount: UInt64?
var changeAddress: String?
var inputCount: Int = 0
/// Balance
var availableBalance: UInt64 = 0
private let bitcoinService: any BitcoinServiceProtocol
// MARK: - PSBTFlowManaging
var psbtBitcoinService: any BitcoinServiceProtocol {
bitcoinService
}
func navigateAfterSign() {
if needsMoreSignatures {
currentStep = .psbtDisplay
} else {
currentStep = .broadcast
}
}
init(bitcoinService: (any BitcoinServiceProtocol)? = nil) {
self.bitcoinService = bitcoinService ?? BitcoinService.shared
}
var frozenOutpoints: Set<String> = []
var allUTXOs: [UTXOItem] {
bitcoinService.utxos
}
var spendableUTXOs: [UTXOItem] {
bitcoinService.utxos.filter { !frozenOutpoints.contains($0.id) }
}
var frozenUTXOs: [UTXOItem] {
bitcoinService.utxos.filter { frozenOutpoints.contains($0.id) }
}
func isFrozen(_ utxo: UTXOItem) -> Bool {
frozenOutpoints.contains(utxo.id)
}
var selectedUTXOTotal: UInt64 {
guard manualUTXOSelection else { return availableBalance }
return allUTXOs
.filter { selectedUTXOIds.contains($0.id) }
.reduce(0) { $0 + $1.amount }
}
var currentNetwork: BitcoinNetwork? {
bitcoinService.currentNetwork
}
var feeRateValue: Double {
Double(feeRateSatVb) ?? 0
}
var isValidFeeRate: Bool {
feeRateValue > 0
}
var hasAnyInput: Bool {
recipients.contains { !$0.isAddressEmpty || !$0.isAmountEmpty || !$0.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|| manualUTXOSelection
}
var isReviewReady: Bool {
!recipients.isEmpty && recipients.allSatisfy { !$0.isAddressEmpty && (!$0.isAmountEmpty || $0.isSendMax) }
}
var hasValidRecipients: Bool {
!recipients.isEmpty && recipients.allSatisfy { r in
r.isValidAddress && (r.isValidAmount || (r.isSendMax && r.amountValue != nil))
}
}
/// Check if a send-max recipient has zero amount (blocks review)
var hasSendMaxZeroAmount: Bool {
recipients.contains { $0.isSendMax && ($0.amountValue ?? 0) == 0 }
}
var isBalanceExceeded: Bool {
guard !hasSendMax else { return false } // send-max self-adjusts
let totalAmount = totalSendAmount
let fee = estimateFee()
return totalAmount + fee > selectedUTXOTotal
}
/// Attempt to proceed to review; builds a draft PSBT to get exact fee/change info
func tryReview() {
// If we have a loaded saved PSBT with signatures, go straight to review
// without rebuilding (which would create a new unsigned PSBT and lose signatures)
if savedPSBTId != nil, signaturesCollected > 0, !psbtBytes.isEmpty {
currentStep = .review
return
}
showValidationErrors = true
guard hasValidRecipients, isValidFeeRate, !isBalanceExceeded, !hasSendMaxZeroAmount else { return }
guard recipients.allSatisfy({ $0.isAddressFormatValid(network: currentNetwork) }) else { return }
showValidationErrors = false
Task { await buildDraftPSBT() }
}
var totalSendAmount: UInt64 {
recipients.reduce(0) { $0 + ($1.amountValue ?? 0) }
}
var hasSendMax: Bool {
recipients.contains { $0.isSendMax }
}
/// Build a preview TransactionItem for the send flow detail screen
var previewTransaction: TransactionItem {
let outputs = recipients.map { r in
TransactionItem.TxIO(
address: r.address.trimmingCharacters(in: .whitespacesAndNewlines),
amount: r.amountValue ?? 0,
prevTxid: nil,
prevVout: nil,
isMine: false
)
}
return TransactionItem(
id: broadcastTxid.isEmpty ? "Unsigned" : broadcastTxid,
amount: -Int64(totalSendAmount),
fee: nil,
confirmations: 0,
timestamp: nil,
isIncoming: false,
inputs: [],
outputs: outputs
)
}
/// Total deducted from wallet = send amount + fee
var totalSpendAmount: UInt64 {
totalSendAmount + totalFee
}
/// Total sats of all inputs = send amount + fee + change
var inputsAmount: UInt64 {
totalSendAmount + totalFee + (changeAmount ?? 0)
}
// signatureProgress and needsMoreSignatures provided by PSBTFlowManaging
func loadBalance() {
availableBalance = spendableUTXOs.reduce(0) { $0 + $1.amount }
requiredSignatures = bitcoinService.requiredSignatures
totalCosigners = bitcoinService.totalCosigners
}
// MARK: - Fiat Toggle
private var fiatService: FiatPriceService {
FiatPriceService.shared
}
var canToggleFiat: Bool {
fiatService.currentRate != nil
}
func toggleAmountCurrency() {
guard canToggleFiat else { return }
if amountInFiat {
// Switching from fiat to sats: convert fiat display amounts back to sats
for i in recipients.indices {
if recipients[i].isSendMax { continue }
let fiatStr = fiatDisplayAmount[recipients[i].id] ?? ""
if let fiatVal = Double(fiatStr), fiatVal > 0,
let sats = fiatService.fiatToSats(fiatVal)
{
recipients[i].amountSats = "\(sats)"
}
}
} else {
// Switching from sats to fiat: convert sats to fiat display amounts
for i in recipients.indices {
if recipients[i].isSendMax {
if let sats = recipients[i].amountValue,
let fiatVal = fiatService.satsToFiat(sats)
{
fiatDisplayAmount[recipients[i].id] = String(format: "%.2f", fiatVal)
}
continue
}
if let sats = recipients[i].amountValue,
let fiatVal = fiatService.satsToFiat(sats)
{
fiatDisplayAmount[recipients[i].id] = String(format: "%.2f", fiatVal)
} else {
fiatDisplayAmount[recipients[i].id] = ""
}
}
}
amountInFiat.toggle()
}
/// Called when fiat amount text changes updates the underlying sats value
func updateSatsFromFiat(for index: Int) {
guard amountInFiat, !recipients[index].isSendMax else { return }
let fiatStr = fiatDisplayAmount[recipients[index].id] ?? ""
if let fiatVal = Double(fiatStr), fiatVal > 0,
let sats = fiatService.fiatToSats(fiatVal)
{
recipients[index].amountSats = "\(sats)"
} else {
recipients[index].amountSats = ""
}
}
// MARK: - Recipients
var canAddRecipient: Bool {
!hasSendMax
}
func addRecipient() {
guard canAddRecipient else { return }
recipients.append(Recipient())
}
func removeRecipient(at index: Int) {
guard recipients.count > 1 else { return }
let hadMax = recipients[index].isSendMax
recipients.remove(at: index)
if hadMax {
// MAX was on the deleted recipient clear it entirely
for i in recipients.indices {
recipients[i].isSendMax = false
}
}
}
func toggleUTXOSelection(_ utxoId: String) {
if selectedUTXOIds.contains(utxoId) {
selectedUTXOIds.remove(utxoId)
} else {
selectedUTXOIds.insert(utxoId)
}
recalculateMaxIfNeeded()
}
func setManualUTXOSelection(_ enabled: Bool) {
manualUTXOSelection = enabled
if !enabled {
selectedUTXOIds.removeAll()
}
recalculateMaxIfNeeded()
}
/// MAX is only allowed on the last recipient
var isMaxAllowed: Bool {
true // Always allowed on last recipient; UI hides the button for non-last recipients
}
func toggleMaxAmount(for index: Int) {
guard index == recipients.count - 1 else { return } // only last recipient
// If already max, untoggle and clear
if recipients[index].isSendMax {
recipients[index].isSendMax = false
recipients[index].amountSats = ""
fiatDisplayAmount[recipients[index].id] = ""
return
}
// Clear any other send-max flags (shouldn't exist, but be safe)
for i in recipients.indices {
recipients[i].isSendMax = false
}
recipients[index].isSendMax = true
recalculateMax(for: index)
}
func fetchFeeRates() async {
do {
let rates = try await bitcoinService.getFeeRates()
await MainActor.run {
self.recommendedFees = rates
applyPreset(selectedFeePreset)
}
} catch {
logger.error("Failed to fetch fee rates: \(error)")
}
}
func applyPreset(_ preset: FeePreset) {
selectedFeePreset = preset
if let rate = preset.rate(from: recommendedFees) {
feeRateSatVb = formatFeeRate(rate)
}
}
/// Recalculate the max amount for whichever recipient has isSendMax
func recalculateMaxIfNeeded() {
guard let index = recipients.firstIndex(where: { $0.isSendMax }) else { return }
recalculateMax(for: index)
}
private func recalculateMax(for index: Int) {
let spendableBalance = selectedUTXOTotal
let otherAmounts = recipients.enumerated()
.filter { $0.offset != index }
.reduce(UInt64(0)) { $0 + ($1.element.amountValue ?? 0) }
let feeEstimate = estimateFee()
let maxAmount = spendableBalance > (otherAmounts + feeEstimate) ?
spendableBalance - otherAmounts - feeEstimate : 0
recipients[index].amountSats = "\(maxAmount)"
if amountInFiat, let fiatVal = fiatService.satsToFiat(maxAmount) {
fiatDisplayAmount[recipients[index].id] = String(format: "%.2f", fiatVal)
}
}
func estimateFee() -> UInt64 {
estimatedFee(for: feeRateValue)
}
func estimatedFee(for rate: Double) -> UInt64 {
// Rough estimate: P2WSH multisig input ~200 vbytes, output ~43 vbytes each, overhead ~10
let inputCount: Int = if manualUTXOSelection {
max(selectedUTXOIds.count, 1)
} else {
max(bitcoinService.utxos.count, 1)
}
let outputCount = recipients.count + 1 // +1 for change
let estimatedVbytes = UInt64(inputCount * 200 + outputCount * 43 + 10)
return UInt64(Double(estimatedVbytes) * max(rate, 0.001))
}
/// Parse a BIP-21 URI or plain address string
func parseBIP21(_ input: String, forRecipientAt index: Int) {
recipients[index].parseBIP21(input)
}
// MARK: - PSBT Operations
private var recipientList: [(address: String, amount: UInt64, isSendMax: Bool)] {
recipients.map { r in
(address: r.address.trimmingCharacters(in: .whitespacesAndNewlines),
amount: r.amountValue ?? 0,
isSendMax: r.isSendMax)
}
}
private var selectedUTXOOutpoints: [(txid: String, vout: UInt32)]? {
manualUTXOSelection
? allUTXOs.filter { selectedUTXOIds.contains($0.id) }.map { (txid: $0.txid, vout: $0.vout) }
: nil
}
/// Apply PSBT creation result to view model state
private func applyPSBTResult(_ result: BitcoinService.PSBTResult) {
psbtBase64 = result.base64
psbtBytes = result.bytes
totalFee = result.fee
changeAmount = result.changeAmount
changeAddress = result.changeAddress
inputCount = result.inputCount
if let signerInfo = bitcoinService.psbtSignerInfo(result.bytes) {
signerStatus = signerInfo.cosignerSignStatus
}
}
/// Build a draft PSBT to populate fee/change details, then navigate to review
private func buildDraftPSBT() async {
isProcessing = true
do {
let result = try await bitcoinService.createPSBT(
recipients: recipientList,
feeRate: feeRateValue,
utxos: selectedUTXOOutpoints,
unspendable: frozenOutpoints
)
applyPSBTResult(result)
currentStep = .review
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
func createPSBT() async {
guard recipientList.allSatisfy({ !$0.address.isEmpty && ($0.amount > 0 || $0.isSendMax) }) else {
errorMessage = "Invalid recipient or amount"
return
}
// For manual selection, guard against spending a UTXO the user has frozen.
if let validationError = validateUTXOInputs(outpoints: selectedUTXOOutpoints) {
errorMessage = validationError
return
}
isProcessing = true
do {
let result = try await bitcoinService.createPSBT(
recipients: recipientList,
feeRate: feeRateValue,
utxos: selectedUTXOOutpoints,
unspendable: frozenOutpoints
)
applyPSBTResult(result)
signaturesCollected = 0
currentStep = .psbtDisplay
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
// handleSignedPSBT provided by PSBTFlowManaging default implementation
func finalizeTx() {
guard finalizedTxBytes.isEmpty else { return }
do {
finalizedTxBytes = try bitcoinService.finalizePSBTBytes(psbtBytes)
} catch {
errorMessage = error.localizedDescription
}
}
func broadcast() async {
isProcessing = true
do {
let txid = try await bitcoinService.broadcastPSBT(psbtBytes)
broadcastTxid = txid
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
/// Validate that no frozen UTXOs are in the input set. Returns an error message if any are found, nil otherwise.
func validateUTXOInputs(outpoints: [(txid: String, vout: UInt32)]?) -> String? {
guard let outpoints else { return nil }
let frozenInInputs = outpoints.filter { frozenOutpoints.contains("\($0.txid):\($0.vout)") }
if !frozenInInputs.isEmpty {
return "Cannot create transaction: \(frozenInInputs.count) frozen UTXO(s) in inputs"
}
return nil
}
// MARK: - Saved PSBT
func defaultPSBTName() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy h:mm a"
return formatter.string(from: Date())
}
func savePSBT(name: String, context: ModelContext) {
guard let walletID = BitcoinService.shared.currentProfile?.id else { return }
let trimmedName = String(name.prefix(SavedPSBT.maxNameLength))
let recipientData = encodeRecipients()
let utxoIdsString = selectedUTXOIds.sorted().joined(separator: ",")
let outpoints = bitcoinService.psbtInputOutpoints(psbtBytes).joined(separator: ",")
if let existingId = savedPSBTId {
let descriptor = FetchDescriptor<SavedPSBT>(predicate: #Predicate { $0.id == existingId })
if let existing = try? context.fetch(descriptor).first {
existing.name = trimmedName
existing.psbtBytes = psbtBytes
existing.psbtBase64 = psbtBase64
existing.signaturesCollected = signaturesCollected
existing.updatedAt = Date()
existing.recipientsJSON = recipientData
existing.feeRateSatVb = feeRateSatVb
existing.totalFee = totalFee
existing.changeAmount = changeAmount
existing.changeAddress = changeAddress
existing.inputCount = inputCount
existing.manualUTXOSelection = manualUTXOSelection
existing.selectedUTXOIds = utxoIdsString
existing.inputOutpoints = outpoints
do {
try context.save()
} catch {
logger.error("Failed to update saved PSBT: \(error)")
errorMessage = "Failed to save PSBT: \(error.localizedDescription)"
}
return
}
}
let saved = SavedPSBT(
walletID: walletID,
name: trimmedName,
psbtBytes: psbtBytes,
psbtBase64: psbtBase64,
signaturesCollected: signaturesCollected,
requiredSignatures: requiredSignatures,
recipientsJSON: recipientData,
feeRateSatVb: feeRateSatVb,
totalFee: totalFee,
changeAmount: changeAmount,
changeAddress: changeAddress,
inputCount: inputCount,
manualUTXOSelection: manualUTXOSelection,
selectedUTXOIds: utxoIdsString,
inputOutpoints: outpoints
)
context.insert(saved)
do {
try context.save()
savedPSBTId = saved.id
} catch {
logger.error("Failed to save new PSBT: \(error)")
errorMessage = "Failed to save PSBT: \(error.localizedDescription)"
}
}
// autoSavePSBT provided by PSBTFlowManaging default implementation
func loadSavedPSBT(_ saved: SavedPSBT) {
if let decoded = try? JSONDecoder().decode([SavedRecipient].self, from: saved.recipientsJSON) {
recipients = decoded.map(Recipient.init(from:))
}
feeRateSatVb = saved.feeRateSatVb
totalFee = saved.totalFee
changeAmount = saved.changeAmount
changeAddress = saved.changeAddress
inputCount = saved.inputCount
psbtBytes = saved.psbtBytes
psbtBase64 = saved.psbtBase64
signaturesCollected = saved.signaturesCollected
requiredSignatures = saved.requiredSignatures
manualUTXOSelection = saved.manualUTXOSelection
if !saved.selectedUTXOIds.isEmpty {
selectedUTXOIds = Set(saved.selectedUTXOIds.split(separator: ",").map(String.init))
} else {
selectedUTXOIds = []
}
savedPSBTId = saved.id
savedPSBTName = saved.name
// Populate cosigner signing status from PSBT
if let signerInfo = bitcoinService.psbtSignerInfo(saved.psbtBytes) {
signaturesCollected = signerInfo.totalSignatures
signerStatus = signerInfo.cosignerSignStatus
}
currentStep = .review
}
// deleteSavedPSBT provided by PSBTFlowManaging default implementation
// MARK: - Import PSBT
func importPSBT(_ psbtData: Data, source: String, context: ModelContext) {
do {
let result = try bitcoinService.validateAndParseImportedPSBT(psbtData, frozenOutpoints: frozenOutpoints)
recipients = result.recipients.map(Recipient.init(from:))
feeRateSatVb = result.feeRateSatVb
totalFee = result.fee
changeAmount = result.changeAmount
changeAddress = result.changeAddress
inputCount = result.inputCount
psbtBytes = result.psbtBytes
psbtBase64 = result.psbtBase64
requiredSignatures = bitcoinService.requiredSignatures
totalCosigners = bitcoinService.totalCosigners
// Determine signature status
if let signerInfo = bitcoinService.psbtSignerInfo(result.psbtBytes) {
signaturesCollected = signerInfo.totalSignatures
signerStatus = signerInfo.cosignerSignStatus
} else {
signaturesCollected = 0
signerStatus = []
}
// Save immediately
let importName = "Imported via \(source) \(defaultPSBTName())"
savePSBT(name: importName, context: context)
savedPSBTName = importName
// Navigate to review
currentStep = .review
} catch {
errorMessage = error.localizedDescription
}
}
private func encodeRecipients() -> Data {
let savedRecipients = recipients.map { r in
SavedRecipient(
address: r.address.trimmingCharacters(in: .whitespacesAndNewlines),
amountSats: r.amountSats,
isSendMax: r.isSendMax,
label: r.label
)
}
return (try? JSONEncoder().encode(savedRecipients)) ?? Data()
}
func reset() {
currentStep = .recipients
recipients = [Recipient()]
amountInFiat = false
fiatDisplayAmount.removeAll()
feeRateSatVb = ""
recommendedFees = nil
selectedFeePreset = .medium
psbtBase64 = ""
psbtBytes = Data()
totalFee = 0
changeAmount = nil
changeAddress = nil
inputCount = 0
signaturesCollected = 0
signerStatus = []
broadcastTxid = ""
finalizedTxBytes = Data()
errorMessage = nil
isProcessing = false
showValidationErrors = false
showExportQR = false
showAddressScanner = false
manualUTXOSelection = false
selectedUTXOIds.removeAll()
showUTXOPicker = false
savedPSBTId = nil
savedPSBTName = ""
showSavePSBT = false
showSavedConfirmation = false
showLoadPSBT = false
showImportPSBTQR = false
showImportPSBTFile = false
}
}