hellbender-wallet/hellbender/ViewModels/SetupWizardViewModel.swift

486 lines
17 KiB
Swift

import BitcoinDevKit
import Foundation
import Observation
import SwiftData
@Observable
@MainActor
final class SetupWizardViewModel {
enum Step: Int, CaseIterable {
case welcome
case creationChoice
case multisigConfig
case cosignerImport
case descriptorImport
case walletName
case review
}
enum CreationMode {
case createNew
case importDescriptor
}
// Navigation
var currentStep: Step = .welcome
var creationMode: CreationMode = .createNew
// Multisig config
var requiredSignatures: Int = 2
var totalCosigners: Int = 3
// Cosigner data
var cosignerLabels: [String] = []
var cosignerXpubs: [String] = []
var cosignerFingerprints: [String] = []
var cosignerDerivationPaths: [String] = []
var currentCosignerIndex: Int = 0
/// Import
var importedDescriptorText: String = ""
/// Wallet name
var walletName: String = ""
// Computed descriptors
var externalDescriptor: String = ""
var internalDescriptor: String = ""
// Electrum server
var electrumHost: String = ""
var electrumPort: String = ""
var electrumSSL: Int = 0 // 0 = network default, 1 = TCP, 2 = SSL
var electrumAllowInsecureSSL: Bool = false
/// Returns an error message if the descriptor contains keys that don't match the selected network, nil otherwise.
var descriptorNetworkMismatchError: String? {
let text = importedDescriptorText
guard !text.isEmpty else { return nil }
let hasTestnetKeys = text.contains("tpub") || text.contains("Vpub")
let hasMainnetKeys = text.contains("xpub") || text.contains("Zpub")
if network == .mainnet, hasTestnetKeys, !hasMainnetKeys {
return "Testnet descriptors cannot be used on mainnet"
}
if network != .mainnet, hasMainnetKeys, !hasTestnetKeys {
return "Mainnet descriptors cannot be used on testnet/signet"
}
return nil
}
var isElectrumHostRequired: Bool {
network.defaultElectrumHost == nil
}
var isElectrumHostValid: Bool {
!isElectrumHostRequired || !electrumHost.trimmingCharacters(in: .whitespaces).isEmpty
}
// Advanced settings
var blockExplorerHost: String = ""
var addressGapLimit: String = "20"
// State
var errorMessage: String?
var importDescriptorError: String?
var isProcessing: Bool = false
var network: BitcoinNetwork = .testnet4
/// Set to true after the wallet has been created during the import flow.
var walletCreated: Bool = false
/// Progress
var stepCount: Int {
creationMode == .createNew ? 5 : 3
}
var currentStepIndex: Int {
switch currentStep {
case .welcome: 0
case .creationChoice: 1
case .multisigConfig: 2
case .cosignerImport: 3
case .descriptorImport: 2
case .walletName: creationMode == .createNew ? 4 : 3
case .review: stepCount - 1
}
}
var progress: Double {
Double(currentStepIndex) / Double(stepCount - 1)
}
// MARK: - Cosigner Management
func initializeCosigners() {
let count = totalCosigners
cosignerLabels = (0 ..< count).map { "Cosigner \($0 + 1)" }
cosignerXpubs = Array(repeating: "", count: count)
cosignerFingerprints = Array(repeating: "", count: count)
cosignerDerivationPaths = Array(repeating: Constants.derivationPath(for: network), count: count)
currentCosignerIndex = 0
}
var allCosignersComplete: Bool {
cosignerXpubs.allSatisfy { !$0.isEmpty }
&& cosignerFingerprints.allSatisfy { !$0.isEmpty }
}
var currentCosignerComplete: Bool {
guard currentCosignerIndex < cosignerXpubs.count else { return false }
return !cosignerXpubs[currentCosignerIndex].isEmpty
&& !cosignerFingerprints[currentCosignerIndex].isEmpty
}
// MARK: - Validation
func validateCosignerXpub(_ xpub: String, at index: Int) -> String? {
if xpub.isEmpty { return "Xpub is required" }
let expectedPrefixes = network == .mainnet ? ["xpub", "Zpub"] : ["tpub", "Vpub"]
if !expectedPrefixes.contains(where: { xpub.hasPrefix($0) }) {
return "Expected \(expectedPrefixes.joined(separator: " or ")) prefix for \(network.displayName)"
}
// Check for duplicates
for (i, existing) in cosignerXpubs.enumerated() where i != index {
if !existing.isEmpty, existing == xpub {
return "Duplicate xpub (same as Cosigner \(i + 1))"
}
}
return nil
}
func validateDerivationPath(_ path: String) -> String? {
SetupWizardViewModel.validateDerivationPath(path, for: network)
}
static func validateDerivationPath(_ path: String, for network: BitcoinNetwork) -> String? {
let bip48Pattern = #"^m/48'/[01]'/\d+'/2'$"#
guard path.range(of: bip48Pattern, options: .regularExpression) != nil else {
return "Invalid derivation path. Expected BIP48 format: \(Constants.derivationPath(for: network))"
}
// components: ["m", "48'", "<coinType>'", "<account>'", "2'"]
let components = path.split(separator: "/")
guard components.count == 5 else {
return "Invalid derivation path structure"
}
let coinTypeComponent = String(components[2]) // e.g. "0'" or "1'"
let expectedCoinType = "\(network.coinType)'"
if coinTypeComponent != expectedCoinType {
let pathNetwork = coinTypeComponent == "0'" ? "mainnet" : "testnet/signet"
return "Derivation path coin type is for \(pathNetwork) but wallet network is \(network.displayName). Expected \(Constants.derivationPath(for: network))."
}
return nil
}
func validateFingerprint(_ fp: String) -> String? {
if fp.isEmpty { return "Fingerprint is required" }
if fp.count != 8 { return "Fingerprint must be 8 hex characters" }
if !fp.allSatisfy(\.isHexDigit) { return "Fingerprint must be hex characters only" }
return nil
}
// MARK: - Descriptor Building
func buildDescriptors() {
guard allCosignersComplete else { return }
// Build key origin strings and sort by xpub (BIP67 lexicographic sort)
var keyEntries: [(origin: String, xpub: String, fingerprint: String, path: String, label: String, index: Int)] = []
for i in 0 ..< totalCosigners {
keyEntries.append((
origin: "[\(cosignerFingerprints[i])/48'/\(network.coinType)'/0'/2']",
xpub: cosignerXpubs[i],
fingerprint: cosignerFingerprints[i],
path: cosignerDerivationPaths[i],
label: cosignerLabels[i],
index: i
))
}
// Sort by xpub for BIP67 compliance
keyEntries.sort { $0.xpub < $1.xpub }
let externalKeys = keyEntries.map {
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/0/*"
}.joined(separator: ",")
let internalKeys = keyEntries.map {
let xpub = $0.xpub.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "\($0.origin)\(xpub)/1/*"
}.joined(separator: ",")
externalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(externalKeys)))"
internalDescriptor = "wsh(sortedmulti(\(requiredSignatures),\(internalKeys)))"
}
func parseImportedDescriptor() -> Bool {
// If the input is a JSON object (e.g. Specter Desktop export), extract the descriptor field
if let descriptor = URService.extractDescriptorFromJSON(importedDescriptorText) {
importedDescriptorText = descriptor
}
// Remove all whitespace and newlines descriptors may be pasted in multiline format
var text = importedDescriptorText.components(separatedBy: .whitespacesAndNewlines).joined()
guard !text.isEmpty else {
errorMessage = "Descriptor is empty"
return false
}
// Strip checksum (e.g. #2kjudevd)
if let hashIndex = text.lastIndex(of: "#") {
text = String(text[text.startIndex ..< hashIndex])
}
// Normalize smart/curly quotes to ASCII apostrophes (iOS keyboard substitution)
for smartQuote in ["\u{2018}", "\u{2019}", "\u{02BC}"] {
text = text.replacingOccurrences(of: smartQuote, with: "'")
}
// Normalize hardened notation: h '
text = text.replacingOccurrences(of: "h/", with: "'/")
text = text.replacingOccurrences(of: "h]", with: "']")
text = text.replacingOccurrences(of: "h)", with: "')")
// Basic validation - check it starts with wsh(sortedmulti(
guard text.hasPrefix("wsh(sortedmulti(") else {
errorMessage = "Descriptor must be wsh(sortedmulti(...)) format"
return false
}
// Extract M value
let afterPrefix = text.dropFirst("wsh(sortedmulti(".count)
guard let commaIndex = afterPrefix.firstIndex(of: ",") else {
errorMessage = "Cannot parse M value from descriptor"
return false
}
guard let m = Int(afterPrefix[afterPrefix.startIndex ..< commaIndex]) else {
errorMessage = "Invalid M value"
return false
}
// Handle BIP-389 multipath descriptors: /<0;1>/* split into /0/* and /1/*
if text.contains("<0;1>/*") {
externalDescriptor = text.replacingOccurrences(of: "<0;1>/*", with: "0/*")
internalDescriptor = text.replacingOccurrences(of: "<0;1>/*", with: "1/*")
} else if text.contains("<1;0>/*") {
externalDescriptor = text.replacingOccurrences(of: "<1;0>/*", with: "0/*")
internalDescriptor = text.replacingOccurrences(of: "<1;0>/*", with: "1/*")
} else if text.contains("{0,1}/*") {
// Pre-BIP389 Specter DIY format: {0,1}/* split into /0/* and /1/*
externalDescriptor = text.replacingOccurrences(of: "{0,1}/*", with: "0/*")
internalDescriptor = text.replacingOccurrences(of: "{0,1}/*", with: "1/*")
} else if text.contains("{1,0}/*") {
externalDescriptor = text.replacingOccurrences(of: "{1,0}/*", with: "0/*")
internalDescriptor = text.replacingOccurrences(of: "{1,0}/*", with: "1/*")
} else if text.contains("/0/*") {
// Standard single-path descriptor (external)
externalDescriptor = text
internalDescriptor = text.replacingOccurrences(of: "/0/*", with: "/1/*")
} else if text.contains("/1/*") {
// Standard single-path descriptor (internal)
internalDescriptor = text
externalDescriptor = text.replacingOccurrences(of: "/1/*", with: "/0/*")
} else {
// No derivation suffix (e.g. Specter Desktop format) append /0/* and /1/*
// Replace each bare xpub (followed by , or )) with xpub/0/* for external
let xpubPattern = #"([xt]pub[a-zA-Z0-9]+)(?=[,)])"#
if let xpubRegex = try? NSRegularExpression(pattern: xpubPattern) {
let nsText = text as NSString
externalDescriptor = xpubRegex.stringByReplacingMatches(
in: text, range: NSRange(location: 0, length: nsText.length),
withTemplate: "$1/0/*"
)
internalDescriptor = xpubRegex.stringByReplacingMatches(
in: text, range: NSRange(location: 0, length: nsText.length),
withTemplate: "$1/1/*"
)
} else {
externalDescriptor = text
internalDescriptor = text
}
}
// Parse cosigner info from key origins
// Supports both ' and h for hardened notation (already normalized to ' above)
let pattern = #"\[([0-9a-fA-F]{8})/48'/([01])'/(\d+)'/2'\]([xt]pub[a-zA-Z0-9]+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else {
errorMessage = "Failed to parse cosigner keys"
return false
}
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
if matches.isEmpty {
errorMessage = "No cosigner keys found in descriptor"
return false
}
requiredSignatures = m
totalCosigners = matches.count
cosignerLabels = []
cosignerXpubs = []
cosignerFingerprints = []
cosignerDerivationPaths = []
for (i, match) in matches.enumerated() {
let fp = nsText.substring(with: match.range(at: 1))
let coin = nsText.substring(with: match.range(at: 2))
let account = nsText.substring(with: match.range(at: 3))
let xpub = nsText.substring(with: match.range(at: 4))
cosignerLabels.append("Cosigner \(i + 1)")
cosignerFingerprints.append(fp)
cosignerXpubs.append(xpub)
cosignerDerivationPaths.append("m/48'/\(coin)'/\(account)'/2'")
}
if requiredSignatures > totalCosigners {
errorMessage = "M (\(requiredSignatures)) cannot be greater than N (\(totalCosigners))"
return false
}
return true
}
// MARK: - Navigation
func goToNext() {
switch currentStep {
case .welcome:
currentStep = .creationChoice
case .creationChoice:
if creationMode == .createNew {
currentStep = .multisigConfig
} else {
currentStep = .descriptorImport
}
case .multisigConfig:
guard isElectrumHostValid else {
errorMessage = "An Electrum server host is required for \(network.displayName)"
return
}
initializeCosigners()
currentStep = .cosignerImport
case .cosignerImport:
buildDescriptors()
currentStep = .walletName
case .descriptorImport:
importDescriptorError = nil
guard isElectrumHostValid else {
importDescriptorError = "An Electrum server host is required for \(network.displayName)"
return
}
guard parseImportedDescriptor() else {
importDescriptorError = errorMessage
errorMessage = nil
return
}
// Validate descriptors with BDK before proceeding
let bdkNetwork = BitcoinService.shared.bdkNetwork(from: network)
do {
_ = try Descriptor(descriptor: externalDescriptor, network: bdkNetwork)
_ = try Descriptor(descriptor: internalDescriptor, network: bdkNetwork)
} catch {
importDescriptorError = "Invalid descriptor: \(error.localizedDescription)"
return
}
currentStep = .walletName
case .walletName:
currentStep = .review
case .review:
break // handled by saveWallet
}
}
func goBack() {
switch currentStep {
case .welcome: break
case .creationChoice: currentStep = .welcome
case .multisigConfig: currentStep = .creationChoice
case .cosignerImport: currentStep = .multisigConfig
case .descriptorImport: currentStep = .creationChoice
case .walletName:
if creationMode == .createNew {
currentStep = .cosignerImport
}
// Import flow: back button is hidden, wallet already created
case .review: currentStep = .walletName
}
}
// MARK: - Save
func saveWallet(modelContext: ModelContext) throws {
guard isElectrumHostValid else {
throw AppError.electrumConnectionFailed("An Electrum server host is required for \(network.displayName)")
}
// Validate descriptors with BDK before saving catch parse errors early
let bdkNetwork = BitcoinService.shared.bdkNetwork(from: network)
do {
_ = try Descriptor(descriptor: externalDescriptor, network: bdkNetwork)
_ = try Descriptor(descriptor: internalDescriptor, network: bdkNetwork)
} catch {
throw AppError.descriptorInvalid("\(error.localizedDescription)")
}
// Deactivate all existing wallets
let fetchDescriptor = FetchDescriptor<WalletProfile>()
let existingWallets = try modelContext.fetch(fetchDescriptor)
for wallet in existingWallets {
wallet.isActive = false
}
// Create new wallet
let profile = WalletProfile(
name: walletName.isEmpty ? "My Wallet" : walletName,
requiredSignatures: requiredSignatures,
totalCosigners: totalCosigners,
externalDescriptor: externalDescriptor,
internalDescriptor: internalDescriptor,
network: network,
isActive: true,
addressGapLimit: Int(addressGapLimit) ?? 50,
electrumHost: electrumHost.trimmingCharacters(in: .whitespaces),
electrumPort: Int(electrumPort) ?? 0,
electrumSSL: electrumSSL,
electrumAllowInsecureSSL: electrumAllowInsecureSSL,
blockExplorerHost: blockExplorerHost.trimmingCharacters(in: .whitespaces)
)
modelContext.insert(profile)
// Create cosigner records
for i in 0 ..< totalCosigners {
let cosigner = CosignerInfo(
label: cosignerLabels[i],
xpub: cosignerXpubs[i],
fingerprint: cosignerFingerprints[i],
derivationPath: cosignerDerivationPaths[i],
orderIndex: i
)
cosigner.wallet = profile
modelContext.insert(cosigner)
}
// Set UserDefaults before save so both are in place if app is killed after save
UserDefaults.standard.set(profile.id.uuidString, forKey: Constants.activeWalletIDKey)
UserDefaults.standard.set(true, forKey: Constants.hasCompletedOnboardingKey)
do {
try modelContext.save()
} catch {
// Rollback UserDefaults if save fails
UserDefaults.standard.removeObject(forKey: Constants.activeWalletIDKey)
UserDefaults.standard.removeObject(forKey: Constants.hasCompletedOnboardingKey)
throw error
}
}
}