Release v0.1.2b21

This commit is contained in:
Nick Klockenga 2026-03-28 23:18:14 -04:00
parent 084dafe9b9
commit b7b0e7da3c
60 changed files with 2728 additions and 826 deletions

View File

@ -419,7 +419,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -430,8 +430,8 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@ -452,7 +452,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 16;
CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\"";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -463,8 +463,8 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",

View File

@ -1,28 +1,34 @@
import LocalAuthentication
import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "AppLifecycle")
struct ContentView: View {
@Query private var wallets: [WalletProfile]
@AppStorage(Constants.hasCompletedOnboardingKey) private var hasCompletedOnboarding = false
@AppStorage(Constants.appLockEnabledKey) private var appLockEnabled = false
@AppStorage(Constants.appLockTimeoutKey) private var lockTimeout = 60
@Environment(\.scenePhase) private var scenePhase
@State private var isLocked = true
@State private var isAuthenticating = false
@State private var backgroundTime: Date?
@Environment(\.modelContext) private var modelContext
@State private var lockVM = AppLockViewModel()
private var hasActiveWallet: Bool {
wallets.contains { $0.isActive }
}
private var shouldShowLock: Bool {
appLockEnabled && isLocked
appLockEnabled && lockVM.isLocked
}
var body: some View {
ZStack {
Group {
if hasCompletedOnboarding, hasActiveWallet {
if shouldShowLock {
// Don't render main UI while locked prevents wallet load/sync
Color.hbBackground.ignoresSafeArea()
} else if hasCompletedOnboarding, hasActiveWallet {
MainTabView()
} else {
SetupWizardView()
@ -31,52 +37,44 @@ struct ContentView: View {
.background(Color.hbBackground)
if shouldShowLock {
AppLockView(isAuthenticating: $isAuthenticating, onAuthenticate: authenticate)
AppLockView(lockVM: lockVM, modelContext: modelContext)
}
}
.onAppear {
// If wallets exist but none are active (e.g. after a failed delete),
// activate the first one so the app doesn't fall through to the setup wizard.
if !wallets.isEmpty, !hasActiveWallet {
logger.info("No active wallet found — activating first available wallet")
let first = wallets[0]
first.isActive = true
UserDefaults.standard.set(first.id.uuidString, forKey: Constants.activeWalletIDKey)
try? modelContext.save()
}
if appLockEnabled {
authenticate()
logger.info("App launched with lock enabled")
lockVM.authenticate()
} else {
isLocked = false
logger.info("App launched (lock disabled)")
lockVM.isLocked = false
}
}
.onChange(of: scenePhase) { _, newPhase in
if appLockEnabled {
if newPhase == .background {
if backgroundTime == nil {
backgroundTime = Date()
}
} else if newPhase == .active {
if let bgTime = backgroundTime, Date().timeIntervalSince(bgTime) >= 60 {
isLocked = true
authenticate()
}
backgroundTime = nil
switch newPhase {
case .background:
logger.info("Scene phase: background")
if appLockEnabled {
BitcoinService.shared.stopAutoSync()
lockVM.handleBackground()
}
}
}
}
private func authenticate() {
guard !isAuthenticating else { return }
isAuthenticating = true
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
// If authentication is unavailable, let the user in
isLocked = false
isAuthenticating = false
return
}
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock \(Constants.appName)") { success, _ in
DispatchQueue.main.async {
if success {
isLocked = false
case .active:
logger.info("Scene phase: active")
if appLockEnabled {
lockVM.handleForeground(timeout: lockTimeout)
}
isAuthenticating = false
case .inactive:
logger.info("Scene phase: inactive")
@unknown default:
break
}
}
}
@ -85,8 +83,9 @@ struct ContentView: View {
// MARK: - Lock Screen
private struct AppLockView: View {
@Binding var isAuthenticating: Bool
let onAuthenticate: () -> Void
@Bindable var lockVM: AppLockViewModel
let modelContext: ModelContext
@State private var lockoutTimer: Timer?
private var biometricIcon: String {
let context = LAContext()
@ -104,30 +103,97 @@ private struct AppLockView: View {
Color.hbBackground
.ignoresSafeArea()
VStack(spacing: 24) {
Spacer()
if lockVM.needsPINEntry {
pinEntryView
} else {
biometricView
}
}
.onAppear {
startLockoutTimerIfNeeded()
}
.onDisappear {
lockoutTimer?.invalidate()
}
}
Image(systemName: biometricIcon)
.font(.system(size: 56))
.foregroundStyle(Color.hbBitcoinOrange)
private var biometricView: some View {
VStack(spacing: 24) {
Spacer()
Text(Constants.appName)
.font(.hbDisplay(28))
.foregroundStyle(Color.hbTextPrimary)
Image(systemName: biometricIcon)
.font(.system(size: 56))
.foregroundStyle(Color.hbBitcoinOrange)
Text("Locked")
.font(.hbBody())
.foregroundStyle(Color.hbTextSecondary)
Text(Constants.appName)
.font(.hbDisplay(28))
.foregroundStyle(Color.hbTextPrimary)
Spacer()
Text("Locked")
.font(.hbBody())
.foregroundStyle(Color.hbTextSecondary)
Button(action: onAuthenticate) {
Text("Unlock")
.hbPrimaryButton()
Spacer()
Button(action: { lockVM.authenticate() }) {
Text("Unlock")
.hbPrimaryButton()
}
.disabled(lockVM.isAuthenticating)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
private var pinEntryView: some View {
VStack(spacing: 0) {
Spacer()
PINPadView(
title: "Enter PIN",
subtitle: lockVM.pinError,
dotCount: lockVM.storedPINLength,
minDigits: lockVM.storedPINLength,
mode: .verify,
pin: $lockVM.pinInput,
isDisabled: lockVM.isLockedOut,
onComplete: { pin in
let success = lockVM.verifyPIN(pin)
if !success {
if lockVM.failedAttempts >= 10 {
lockVM.wipeAllData(modelContext: modelContext)
}
startLockoutTimerIfNeeded()
}
},
onFaceIDTap: {
lockVM.needsPINEntry = false
lockVM.pinInput = ""
lockVM.pinError = ""
lockVM.authenticate()
}
)
Spacer()
}
.padding(.horizontal, 16)
}
private func startLockoutTimerIfNeeded() {
guard lockVM.isLockedOut else { return }
lockoutTimer?.invalidate()
lockoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak lockVM] timer in
MainActor.assumeIsolated {
guard let lockVM else {
timer.invalidate()
return
}
if !lockVM.isLockedOut {
timer.invalidate()
lockVM.pinError = ""
} else {
lockVM.pinError = lockVM.lockoutRemainingText
}
.disabled(isAuthenticating)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
}

View File

@ -30,7 +30,7 @@ enum BitcoinNetwork: String, CaseIterable, Codable, Identifiable {
switch self {
case .mainnet: nil
case .testnet4: "testnet4.mempool.space"
case .testnet3: "testnet.aranguren.org"
case .testnet3: "electrum.blockstream.info"
case .signet: "signet.mempool.space"
}
}
@ -39,7 +39,7 @@ enum BitcoinNetwork: String, CaseIterable, Codable, Identifiable {
switch self {
case .mainnet: 50002
case .testnet4: 40002
case .testnet3: 51002
case .testnet3: 60002
case .signet: 60602
}
}

View File

@ -0,0 +1,24 @@
import Foundation
enum FeePreset: CaseIterable {
case fast, medium, slow, custom
var displayName: String {
switch self {
case .fast: "Fast"
case .medium: "Medium"
case .slow: "Slow"
case .custom: "Custom"
}
}
func rate(from fees: BitcoinService.RecommendedFees?) -> Double? {
guard let fees else { return nil }
switch self {
case .fast: return fees.fast
case .medium: return fees.medium
case .slow: return fees.slow
case .custom: return nil
}
}
}

View File

@ -2,7 +2,9 @@ import SwiftUI
import UniformTypeIdentifiers
struct PSBTFileDocument: FileDocument {
static var readableContentTypes: [UTType] { [.data] }
static var readableContentTypes: [UTType] {
[.data]
}
let data: Data
@ -14,7 +16,7 @@ struct PSBTFileDocument: FileDocument {
data = configuration.file.regularFileContents ?? Data()
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}

View File

@ -0,0 +1,109 @@
import Foundation
struct Recipient: Identifiable {
let id = UUID()
var address: String = ""
var amountSats: String = ""
var isSendMax: Bool = false
var label: String = ""
var amountValue: UInt64? {
UInt64(amountSats)
}
var isAddressEmpty: Bool {
address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var isValidAddress: Bool {
!isAddressEmpty
}
/// Checks if the address looks like a valid Bitcoin address format
func isAddressFormatValid(network: BitcoinNetwork?) -> Bool {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return true } // empty is not "invalid format", just missing
guard let network else { return true }
let prefix = network.addressPrefix
// Accept bech32/bech32m addresses for the current network
if trimmed.lowercased().hasPrefix(prefix) {
return trimmed.count >= prefix.count + 10 // minimum reasonable length
}
// Also accept legacy P2SH (3...) and P2PKH (1...) on mainnet
if network == .mainnet, trimmed.hasPrefix("3") || trimmed.hasPrefix("1") {
return trimmed.count >= 26 && trimmed.count <= 35
}
// Accept testnet P2SH (2...) and P2PKH (m.../n...)
if network != .mainnet, trimmed.hasPrefix("2") || trimmed.hasPrefix("m") || trimmed.hasPrefix("n") {
return trimmed.count >= 26 && trimmed.count <= 35
}
return false
}
var isValidAmount: Bool {
guard let amount = amountValue else { return false }
return amount > 0
}
var isAmountEmpty: Bool {
amountSats.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Create a Recipient from a SavedRecipient (used when loading/importing PSBTs)
init(from saved: SavedRecipient) {
address = saved.address
amountSats = saved.amountSats
isSendMax = saved.isSendMax
label = saved.label
}
init(address: String = "", amountSats: String = "", isSendMax: Bool = false, label: String = "") {
self.address = address
self.amountSats = amountSats
self.isSendMax = isSendMax
self.label = label
}
/// Parse a BIP-21 URI or plain address string into this recipient
mutating func parseBIP21(_ input: String) {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
// Check for BIP-21 URI: bitcoin:address?amount=0.001&label=...
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
scheme == "bitcoin" || scheme == "BITCOIN".lowercased()
else {
// Plain address
address = trimmed
return
}
// Extract address from path
if let host = url.host(percentEncoded: false), !host.isEmpty {
address = host
} else {
// bitcoin:tb1q... opaque path
let stripped = trimmed.drop(while: { $0 != ":" }).dropFirst()
let addrPart = stripped.prefix(while: { $0 != "?" })
address = String(addrPart)
}
// Parse query parameters
if let components = URLComponents(string: trimmed) {
for item in components.queryItems ?? [] {
switch item.name.lowercased() {
case "amount":
// BIP-21 amount is in BTC, convert to sats
if let btcString = item.value, let btc = Double(btcString) {
let sats = UInt64(btc * 100_000_000)
amountSats = "\(sats)"
isSendMax = false
}
default:
break
}
}
}
}
}

View File

@ -17,6 +17,7 @@ final class WalletProfile {
var electrumPort: Int
var electrumSSL: Int // 0 = network default, 1 = TCP, 2 = SSL
var blockExplorerHost: String // empty = mempool.space
var privacyMode: Bool = false
@Relationship(deleteRule: .cascade, inverse: \CosignerInfo.wallet)
var cosigners: [CosignerInfo]
@ -34,7 +35,8 @@ final class WalletProfile {
electrumHost: String = "",
electrumPort: Int = 0,
electrumSSL: Int = 0,
blockExplorerHost: String = ""
blockExplorerHost: String = "",
privacyMode: Bool = false
) {
self.id = id
self.name = name
@ -50,6 +52,7 @@ final class WalletProfile {
self.electrumPort = electrumPort
self.electrumSSL = electrumSSL
self.blockExplorerHost = blockExplorerHost
self.privacyMode = privacyMode
cosigners = []
}

View File

@ -1,6 +1,8 @@
import BitcoinDevKit
import Combine
import Foundation
import Network
import OSLog
import SwiftData
// MARK: - Fee Source
@ -19,7 +21,10 @@ enum FeeSource: String, CaseIterable {
}
}
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BitcoinService")
@Observable
@MainActor
final class BitcoinService {
static let shared = BitcoinService()
@ -40,6 +45,7 @@ final class BitcoinService {
private(set) var totalCosigners: Int = 1
private(set) var chainTipHeight: UInt32 = 0
private var needsFullScan: Bool = true
var syncTask: Task<Void, Error>?
// Sync state single source of truth
private(set) var syncState: WalletSyncState = .notStarted
@ -47,10 +53,17 @@ final class BitcoinService {
private(set) var lastSyncType: SyncType = .none
private(set) var syncLog: [String] = []
/// Updates syncState only if the given profile is still the active wallet.
/// Prevents a long-running sync from overwriting UI state after a wallet switch.
private func setSyncState(_ state: WalletSyncState, for profileId: UUID?) {
guard currentProfile?.id == profileId else { return }
syncState = state
}
private func addToLog(_ message: String) {
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] \(message)"
print(entry)
logger.info("\(message, privacy: .public)")
syncLog.append(entry)
if syncLog.count > 100 {
syncLog.removeFirst()
@ -68,11 +81,11 @@ final class BitcoinService {
func startAutoSync() {
guard autoSyncCancellable == nil else { return }
autoSyncCancellable = Timer.publish(every: 60, on: .main, in: .common)
autoSyncCancellable = Timer.publish(every: 1800, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self, !self.syncState.isSyncing, wallet != nil else { return }
Task {
syncTask = Task {
try? await self.sync()
}
}
@ -91,36 +104,99 @@ final class BitcoinService {
/// Check if enough time has passed since last sync and sync if needed
func autoSyncIfNeeded() {
guard !syncState.isSyncing, wallet != nil else { return }
if timeSinceLastSync >= 60 {
Task {
if timeSinceLastSync >= 1800 {
syncTask = Task {
try? await self.sync()
}
}
}
/// True only after we have successfully fetched data from the Electrum server.
private(set) var electrumVerified = false
var isElectrumConnected: Bool {
electrumClient != nil
electrumClient != nil && electrumVerified
}
/// Stores the last Electrum connection error for display in the UI.
private(set) var electrumConnectionError: String?
var electrumURL: String? {
guard let profile = currentProfile else { return nil }
return profile.electrumConfig.url
}
/// Returns a user-friendly description for Electrum connection errors,
/// detecting self-signed certificate issues that BDK cannot handle.
static func friendlyElectrumError(_ error: Error) -> String {
let msg = "\(error)"
if msg.contains("InvalidCertificate") || msg.contains("CertificateRequired")
|| msg.contains("BadCertificate") || msg.contains("UnknownIssuer")
{
return "SSL certificate rejected — the server may use a self-signed certificate which BDK does not support. Try using TCP instead of SSL, or use a server with a CA-signed certificate."
}
if msg.contains("AllAttemptsErrored") || msg.contains("CouldNotCreateConnection") {
return "Could not connect to Electrum server — check your network connection and server settings."
}
return msg
}
private init() {}
// MARK: - Wallet Lifecycle
func unloadWallet() {
syncTask?.cancel()
syncTask = nil
stopAutoSync()
wallet = nil
persister = nil
electrumClient = nil
electrumVerified = false
electrumConnectionError = nil
chainTipHeight = 0
currentProfile = nil
balance = 0
transactions = []
utxos = []
syncState = .notStarted
lastSyncDate = nil
syncLog = []
}
func loadWallet(profile: WalletProfile) async throws {
// Cancel any in-flight sync and clear previous wallet state
if currentProfile != nil, currentProfile?.id != profile.id {
addToLog("Switching wallets — unloading previous wallet")
unloadWallet()
}
let network = bdkNetwork(from: profile.bitcoinNetwork)
addToLog("Loading wallet for profile: \(profile.name) (\(profile.id)) on \(profile.bitcoinNetwork.displayName)")
// Auto-repair malformed descriptors (e.g. double slashes from trailing-slash xpubs)
let extDescStr = profile.externalDescriptor.replacingOccurrences(of: "//", with: "/")
let intDescStr = profile.internalDescriptor.replacingOccurrences(of: "//", with: "/")
// Auto-repair malformed descriptors
var extDescStr = profile.externalDescriptor
var intDescStr = profile.internalDescriptor
// Normalize smart/curly quotes to ASCII apostrophes (iOS keyboard substitution)
for smartQuote in ["\u{2018}", "\u{2019}", "\u{02BC}"] {
extDescStr = extDescStr.replacingOccurrences(of: smartQuote, with: "'")
intDescStr = intDescStr.replacingOccurrences(of: smartQuote, with: "'")
}
// Fix double slashes from trailing-slash xpubs
extDescStr = extDescStr.replacingOccurrences(of: "//", with: "/")
intDescStr = intDescStr.replacingOccurrences(of: "//", with: "/")
// Auto-repair BIP-389 multipath notation BDK requires separate /0/* and /1/* descriptors
if extDescStr.contains("<0;1>/*") {
addToLog("Auto-repairing multipath descriptor: splitting <0;1>/* into /0/* and /1/*")
extDescStr = extDescStr.replacingOccurrences(of: "<0;1>/*", with: "0/*")
intDescStr = intDescStr.replacingOccurrences(of: "<0;1>/*", with: "1/*")
}
if extDescStr != profile.externalDescriptor || intDescStr != profile.internalDescriptor {
addToLog("Auto-repaired malformed descriptors (double slashes)")
print("Auto-repaired malformed descriptors (double slashes)")
addToLog("Descriptors auto-repaired — updating profile and clearing stale database")
profile.externalDescriptor = extDescStr
profile.internalDescriptor = intDescStr
// Descriptor changed delete stale BDK database so wallet is recreated
@ -129,22 +205,27 @@ final class BitcoinService {
}
let walletDir = Constants.walletDirectory(for: profile.id)
addToLog("Creating wallet directory: \(walletDir.path)")
try FileManager.default.createDirectory(at: walletDir, withIntermediateDirectories: true)
let dbPath = Constants.walletDatabasePath(for: profile.id)
addToLog("Opening database: \(dbPath.path)")
let persister = try Persister.newSqlite(path: dbPath.path)
addToLog("Parsing external descriptor (\(extDescStr.count) chars): \(extDescStr)")
let externalDesc = try Descriptor(descriptor: extDescStr, network: network)
addToLog("Parsing internal/change descriptor")
let changeDesc = try Descriptor(descriptor: intDescStr, network: network)
// Try loading existing wallet first, create new if not found
let w: Wallet
do {
addToLog("Attempting to load existing wallet from database")
w = try Wallet.load(descriptor: externalDesc, changeDescriptor: changeDesc, persister: persister)
addToLog("Existing wallet loaded from database")
} catch {
// No persisted wallet create fresh
addToLog("Creating new wallet instance")
addToLog("No existing wallet found (\(error)), creating new wallet instance")
w = try Wallet(
descriptor: externalDesc,
changeDescriptor: changeDesc,
@ -182,12 +263,14 @@ final class BitcoinService {
let config = profile.electrumConfig
addToLog("Connecting to Electrum: \(config.url)")
do {
electrumClient = try ElectrumClient(url: config.url)
let url = config.url
electrumClient = try await Task.detached { try ElectrumClient(url: url) }.value
electrumConnectionError = nil
addToLog("Electrum client initialized")
} catch {
electrumClient = nil
electrumConnectionError = Self.friendlyElectrumError(error)
addToLog("Electrum connection failed: \(error)")
print("Electrum connection failed (offline?): \(error)")
}
}
@ -199,7 +282,17 @@ final class BitcoinService {
throw AppError.walletNotLoaded
}
syncState = .syncing("Connecting…")
guard !syncState.isSyncing else {
addToLog("Sync skipped: already in progress")
return
}
// Capture wallet identity and persister at start if the wallet switches
// during sync, we must not apply results or persist to the wrong database.
let syncProfileId = currentProfile?.id
let syncPersister = persister
setSyncState(.syncing("Connecting…"), for: syncProfileId)
addToLog("Starting sync (needsFullScan: \(needsFullScan))")
do {
@ -207,7 +300,9 @@ final class BitcoinService {
if electrumClient == nil, let profile = currentProfile {
let config = profile.electrumConfig
addToLog("Re-initializing Electrum client: \(config.url)")
electrumClient = try ElectrumClient(url: config.url)
let reconnectURL = config.url
electrumClient = try await Task.detached { try ElectrumClient(url: reconnectURL) }.value
electrumConnectionError = nil
}
guard let client = electrumClient else {
@ -215,83 +310,134 @@ final class BitcoinService {
throw AppError.electrumConnectionFailed("No internet connection")
}
// Verify server is reachable before starting scan prevents showing
// misleading "Scanning addresses" progress when offline / server is down.
setSyncState(.syncing("Checking server…"), for: syncProfileId)
try await Task.detached { try client.ping() }.value
addToLog("Server ping OK")
if needsFullScan {
let gapLimit = currentProfile?.addressGapLimit ?? Constants.maxAddressGap
addToLog("Starting full scan (gapLimit: \(gapLimit))")
let inspector = FullScanProgressInspector { [weak self] keychain, index in
let path = keychain == .external ? "0" : "1"
self?.syncState = .syncing("Scanning …/\(path)/\(index)")
Task { @MainActor [weak self] in
self?.setSyncState(.syncing("Scanning …/\(path)/\(index)"), for: syncProfileId)
}
}
let fullScanRequest = try wallet.startFullScan()
.inspectSpksForAllKeychains(inspector: inspector)
.build()
addToLog("Full scan request built")
syncState = .syncing("Scanning addresses…")
let update = try client.fullScan(
request: fullScanRequest,
stopGap: UInt64(gapLimit),
batchSize: 50,
fetchPrevTxouts: true
)
setSyncState(.syncing("Scanning addresses…"), for: syncProfileId)
let update = try await Task.detached { [client] in
try client.fullScan(
request: fullScanRequest,
stopGap: UInt64(gapLimit),
batchSize: 50,
fetchPrevTxouts: true
)
}.value
// Bail out if wallet was switched during the network scan
guard currentProfile?.id == syncProfileId else {
addToLog("Sync cancelled: wallet switched during full scan")
return
}
try Task.checkCancellation()
addToLog("Full scan update received from server")
syncState = .syncing("Applying update…")
setSyncState(.syncing("Applying update…"), for: syncProfileId)
try wallet.applyUpdate(update: update)
addToLog("Full scan update applied to wallet")
needsFullScan = false
lastSyncType = .fullScan
if let profileId = currentProfile?.id {
if let profileId = syncProfileId {
UserDefaults.standard.set(Date(), forKey: "lastFullScanDate_\(profileId.uuidString)")
}
} else {
addToLog("Starting incremental sync")
let inspector = SyncProgressInspector { [weak self] _, total in
self?.syncState = .syncing("Checking \(total) scripts…")
Task { @MainActor [weak self] in
self?.setSyncState(.syncing("Checking \(total) scripts…"), for: syncProfileId)
}
}
let syncRequest = try wallet.startSyncWithRevealedSpks()
.inspectSpks(inspector: inspector)
.build()
addToLog("Sync request built")
syncState = .syncing("Refreshing transactions…")
let update = try client.sync(
request: syncRequest,
batchSize: 50,
fetchPrevTxouts: true
)
setSyncState(.syncing("Refreshing transactions…"), for: syncProfileId)
let update = try await Task.detached { [client] in
try client.sync(
request: syncRequest,
batchSize: 50,
fetchPrevTxouts: true
)
}.value
// Bail out if wallet was switched during the network sync
guard currentProfile?.id == syncProfileId else {
addToLog("Sync cancelled: wallet switched during incremental sync")
return
}
try Task.checkCancellation()
addToLog("Sync update received from server")
syncState = .syncing("Applying update…")
setSyncState(.syncing("Applying update…"), for: syncProfileId)
try wallet.applyUpdate(update: update)
addToLog("Sync update applied to wallet")
lastSyncType = .incremental
}
syncState = .syncing("Saving…")
if let persister {
// Final identity check before persisting
guard currentProfile?.id == syncProfileId else {
addToLog("Sync cancelled: wallet switched before persist")
return
}
setSyncState(.syncing("Saving…"), for: syncProfileId)
if let syncPersister {
addToLog("Persisting wallet state")
_ = try wallet.persist(persister: persister)
_ = try wallet.persist(persister: syncPersister)
}
// Update chain tip height for confirmation count calculation
addToLog("Fetching chain tip height")
if let header = try? client.blockHeadersSubscribe() {
if let header = await Task.detached(operation: { [client] in try? client.blockHeadersSubscribe() }).value {
guard currentProfile?.id == syncProfileId else {
addToLog("Sync cancelled: wallet switched during chain tip fetch")
return
}
chainTipHeight = UInt32(header.height)
addToLog("Chain tip height: \(chainTipHeight)")
}
// Verify wallet identity one more time after final await
guard currentProfile?.id == syncProfileId else {
addToLog("Sync completed but wallet switched — discarding results")
return
}
updateCachedData()
pruneStaleeSavedPSBTs()
let now = Date()
lastSyncDate = now
syncState = .synced(now)
electrumVerified = true
electrumConnectionError = nil
setSyncState(.synced(now), for: syncProfileId)
addToLog("Sync completed successfully")
} catch {
let errorMsg = "\(error)"
addToLog("Sync failed with error: \(errorMsg)")
syncState = .error(errorMsg)
let friendly = Self.friendlyElectrumError(error)
electrumVerified = false
electrumConnectionError = friendly
setSyncState(.error(friendly), for: syncProfileId)
throw error
}
}
@ -301,9 +447,73 @@ final class BitcoinService {
try await sync()
}
func testElectrumConnection(config: ElectrumConfig) async throws {
let client = try ElectrumClient(url: config.url)
try client.ping()
/// Tests connectivity by fetching the chain tip from the server.
/// For SSL connections, validates the certificate first with a native TLS check
/// to detect self-signed certs before BDK obscures the error.
/// Returns the block height on success.
@discardableResult
func testElectrumConnection(config: ElectrumConfig) async throws -> UInt32 {
// Pre-check SSL certificate before handing off to BDK
if config.useSSL {
try await Self.validateTLSCertificate(host: config.host, port: config.port)
}
let url = config.url
let header = try await Task.detached {
let client = try ElectrumClient(url: url)
return try client.blockHeadersSubscribe()
}.value
return UInt32(header.height)
}
/// Performs a native TLS handshake to validate the server's certificate.
/// Throws a clear error if the certificate is self-signed or untrusted.
private static func validateTLSCertificate(host: String, port: UInt16) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
let queue = DispatchQueue(label: "tls-check")
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: .tls
)
connection.stateUpdateHandler = { state in
switch state {
case .ready:
connection.cancel()
continuation.resume()
case let .waiting(error):
connection.cancel()
if case let .tls(osStatus) = error, osStatus == errSecCertificateExpired {
continuation.resume(throwing: AppError.electrumConnectionFailed(
"SSL certificate expired on \(host):\(port)."
))
} else {
continuation.resume(throwing: AppError.electrumConnectionFailed(
"SSL certificate rejected for \(host):\(port) — the server may use a self-signed certificate. Try using TCP instead of SSL, or use a server with a CA-signed certificate."
))
}
case let .failed(error):
connection.cancel()
let desc = error.localizedDescription
if desc.contains("certificate") || desc.contains("SSL") || desc.contains("trust") {
continuation.resume(throwing: AppError.electrumConnectionFailed(
"SSL certificate rejected for \(host):\(port) — the server may use a self-signed certificate. Try using TCP instead of SSL, or use a server with a CA-signed certificate."
))
} else {
continuation.resume(throwing: AppError.electrumConnectionFailed(
"Connection to \(host):\(port) failed: \(desc)"
))
}
case .cancelled:
break
default:
break
}
}
connection.start(queue: queue)
}
}
// MARK: - Data Queries
@ -317,6 +527,11 @@ final class BitcoinService {
let network = bdkNetwork(from: currentProfile?.bitcoinNetwork ?? .testnet4)
let txList = wallet.transactions()
// Build lookup for O(1) input resolution instead of O(n) per input
let txLookup = Dictionary(
txList.map { ($0.transaction.computeTxid().description, $0.transaction) },
uniquingKeysWith: { first, _ in first }
)
transactions = txList.map { canonicalTx -> TransactionItem in
let tx = canonicalTx.transaction
let txid = tx.computeTxid().description
@ -387,8 +602,8 @@ final class BitcoinService {
var inputAmount: UInt64 = 0
var mine = false
var address = prevTxid.truncatedMiddle() + ":\(prevVout)"
if let prevTx = txList.first(where: { $0.transaction.computeTxid().description == prevTxid }) {
let prevOutputs = prevTx.transaction.output()
if let prevTx = txLookup[prevTxid] {
let prevOutputs = prevTx.output()
if prevVout < prevOutputs.count {
let prevOut = prevOutputs[Int(prevVout)]
inputAmount = prevOut.value.toSat()
@ -513,26 +728,32 @@ final class BitcoinService {
while revealed.index < info.index {
revealed = wallet.revealNextAddress(keychain: .external)
}
if let persister {
_ = try wallet.persist(persister: persister)
guard let persister else {
logger.warning("Address revealed without persister — derivation state may be lost")
return (info.address.description, info.index)
}
_ = try wallet.persist(persister: persister)
return (info.address.description, info.index)
}
}
// All peeked addresses are used reveal a new one
let info = wallet.revealNextAddress(keychain: .external)
if let persister {
_ = try wallet.persist(persister: persister)
guard let persister else {
logger.warning("Address revealed without persister — derivation state may be lost")
return (info.address.description, info.index)
}
_ = try wallet.persist(persister: persister)
return (info.address.description, info.index)
}
func revealNextAddress() throws -> (String, UInt32) {
guard let wallet else { throw AppError.walletNotLoaded }
let info = wallet.revealNextAddress(keychain: .external)
if let persister {
_ = try wallet.persist(persister: persister)
guard let persister else {
logger.warning("Address revealed without persister — derivation state may be lost")
return (info.address.description, info.index)
}
_ = try wallet.persist(persister: persister)
return (info.address.description, info.index)
}
@ -871,7 +1092,9 @@ final class BitcoinService {
}
let (_, tx) = try finalizePSBT(psbtData)
return try client.transactionBroadcast(tx: tx).description
return try await Task.detached { [client] in
try client.transactionBroadcast(tx: tx).description
}.value
}
// MARK: - PSBT Import Validation
@ -1096,7 +1319,7 @@ final class BitcoinService {
// MARK: - Helpers
private func bdkNetwork(from network: BitcoinNetwork) -> Network {
func bdkNetwork(from network: BitcoinNetwork) -> Network {
switch network {
case .mainnet: .bitcoin
case .testnet4: .testnet4

View File

@ -1,6 +1,7 @@
import Foundation
/// Protocol abstracting BitcoinService for dependency injection and testing
@MainActor
protocol BitcoinServiceProtocol {
// Properties used by ViewModels
var utxos: [UTXOItem] { get }

View File

@ -1,5 +1,8 @@
import Foundation
import Observation
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "FiatPriceService")
enum FiatSource: String, CaseIterable {
case zeus
@ -22,6 +25,7 @@ final class FiatPriceService {
private(set) var rates: [String: Double] = [:]
private(set) var lastFetched: Date?
private(set) var isFetching = false
private(set) var lastFetchError: String?
private let zeusURL = "https://pay.zeusln.app/api/rates?storeId=Fjt7gLnGpg4UeBMFccLquy3GTTEz4cHU4PZMU63zqMBo"
private let mempoolURL = "https://mempool.space/api/v1/prices"
@ -172,6 +176,7 @@ final class FiatPriceService {
func resetCache() {
rates = [:]
lastFetched = nil
lastFetchError = nil
}
func fetchRatesIfNeeded() async {
@ -212,8 +217,10 @@ final class FiatPriceService {
}
rates = newRates
lastFetched = Date()
lastFetchError = nil
} catch {
print("Failed to fetch Zeus fiat rates: \(error)")
logger.error("Failed to fetch Zeus fiat rates: \(error.localizedDescription)")
lastFetchError = error.localizedDescription
}
}
@ -232,8 +239,10 @@ final class FiatPriceService {
if let v = decoded.JPY { newRates["JPY"] = v }
rates = newRates
lastFetched = Date()
lastFetchError = nil
} catch {
print("Failed to fetch mempool.space fiat rates: \(error)")
logger.error("Failed to fetch mempool.space fiat rates: \(error.localizedDescription)")
lastFetchError = error.localizedDescription
}
}
@ -249,8 +258,10 @@ final class FiatPriceService {
}
rates = newRates
lastFetched = Date()
lastFetchError = nil
} catch {
print("Failed to fetch CoinGecko fiat rates: \(error)")
logger.error("Failed to fetch CoinGecko fiat rates: \(error.localizedDescription)")
lastFetchError = error.localizedDescription
}
}
}

View File

@ -1,6 +1,9 @@
import Foundation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "LabelService")
/// Handles label propagation between transactions, UTXOs, and addresses.
enum LabelService {
/// Called after sync: for each incoming transaction whose receive address has a label,
@ -10,7 +13,7 @@ enum LabelService {
utxos: [UTXOItem],
context: ModelContext,
walletID: UUID
) {
) throws {
let allLabels = fetchAllLabels(walletID: walletID, context: context)
let addrLabels = Dictionary(
allLabels.filter { $0.type == "addr" }.map { ($0.ref, $0.label) },
@ -43,7 +46,14 @@ enum LabelService {
}
}
if didChange { try? context.save() }
if didChange {
do {
try context.save()
} catch {
logger.error("Failed to save propagated address labels: \(error)")
throw error
}
}
}
/// Called after saving a receive transaction's label.
@ -56,7 +66,7 @@ enum LabelService {
utxos: [UTXOItem],
context: ModelContext,
walletID: UUID
) {
) throws {
guard transaction.isIncoming, !newLabel.isEmpty else { return }
let allLabels = fetchAllLabels(walletID: walletID, context: context)
@ -79,7 +89,14 @@ enum LabelService {
}
}
if didChange { try? context.save() }
if didChange {
do {
try context.save()
} catch {
logger.error("Failed to save propagated tx labels: \(error)")
throw error
}
}
}
/// Called after a send transaction is broadcast.
@ -91,7 +108,7 @@ enum LabelService {
changeVout: UInt32,
context: ModelContext,
walletID: UUID
) {
) throws {
guard !txLabel.isEmpty, !changeAddress.isEmpty else { return }
let changeLabel = "Change From: \(txLabel)"
let allLabels = fetchAllLabels(walletID: walletID, context: context)
@ -111,7 +128,14 @@ enum LabelService {
didChange = true
}
if didChange { try? context.save() }
if didChange {
do {
try context.save()
} catch {
logger.error("Failed to save propagated change labels: \(error)")
throw error
}
}
}
// MARK: - BIP 329 Import
@ -125,7 +149,7 @@ enum LabelService {
walletID: UUID,
cosigners: [CosignerInfo],
context: ModelContext
) -> Int {
) throws -> Int {
let records = BIP329Record.parseFromJSONL(data)
let existingLabels = fetchAllLabels(walletID: walletID, context: context)
@ -208,7 +232,14 @@ enum LabelService {
}
}
if importedCount > 0 { try? context.save() }
if importedCount > 0 {
do {
try context.save()
} catch {
logger.error("Failed to save BIP329 import (\(importedCount) labels): \(error)")
throw error
}
}
return importedCount
}
@ -274,7 +305,7 @@ enum LabelService {
changeAddresses: [AddressItem],
cosigners: [CosignerInfo],
requiredSignatures: Int,
network: BitcoinNetwork
network _: BitcoinNetwork
) -> [BIP329Record] {
// Build lookup dictionaries
let labelsByTypeAndRef = Dictionary(
@ -426,6 +457,11 @@ enum LabelService {
private static func fetchAllLabels(walletID: UUID, context: ModelContext) -> [WalletLabel] {
let descriptor = FetchDescriptor<WalletLabel>(predicate: #Predicate { $0.walletID == walletID })
return (try? context.fetch(descriptor)) ?? []
do {
return try context.fetch(descriptor)
} catch {
logger.error("Failed to fetch labels for wallet \(walletID): \(error)")
return []
}
}
}

View File

@ -720,6 +720,17 @@ enum URService {
return .hdKey(xpub: xpub, fingerprint: fingerprint, derivationPath: derivationPath)
}
/// Try to extract a wallet descriptor from a JSON string (e.g. Specter Desktop export).
/// Returns the descriptor string if found, nil otherwise.
static func extractDescriptorFromJSON(_ text: String) -> String? {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let descriptor = json["descriptor"] as? String,
!descriptor.isEmpty
else { return nil }
return descriptor
}
static func processUR(_ ur: UR) -> AppURResult {
switch ur.type {
case "crypto-psbt":

View File

@ -29,6 +29,15 @@ enum Constants {
static let qrDensityKey = "qrDensity"
static let qrFrameRateKey = "qrFrameRate"
static let themeKey = "appTheme"
static let appLockTimeoutKey = "appLockTimeout"
static let appLockPINEnabledKey = "appLockPINEnabled"
// MARK: - Keychain Keys
static let keychainPINHashKey = "com.hellbender.pin.hash"
static let keychainPINLengthKey = "com.hellbender.pin.length"
static let keychainFailedAttemptsKey = "com.hellbender.pin.failedAttempts"
static let keychainLockoutExpiryKey = "com.hellbender.pin.lockoutExpiry"
/// Available auto-refresh intervals in seconds
static let autoRefreshStops: [Double] = [30, 60, 120, 300, 600]
@ -41,7 +50,7 @@ enum Constants {
// MARK: - Limits
static let maxCosigners = 10
static let minCosigners = 2
static let minCosigners = 1
static let maxAddressGap = 50
static func derivationPath(for network: BitcoinNetwork) -> String {
@ -60,4 +69,16 @@ enum Constants {
static func walletDatabasePath(for walletID: UUID) -> URL {
walletDirectory(for: walletID).appendingPathComponent(bdkDatabaseFilename)
}
// MARK: - Privacy Mode
private static let privacySymbols: [Character] = [
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "",
]
static func privacyText(length: Int = 5) -> String {
String((0 ..< length).map { _ in privacySymbols.randomElement()! })
}
}

View File

@ -0,0 +1,51 @@
import Foundation
import Security
enum KeychainHelper {
private static let service = Bundle.main.bundleIdentifier ?? "com.hellbender"
@discardableResult
static func save(_ data: Data, forKey key: String) -> Bool {
delete(forKey: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}
static func load(forKey key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
return nil
}
return result as? Data
}
static func delete(forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
static func deleteAll() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
SecItemDelete(query as CFDictionary)
}
}

View File

@ -0,0 +1,47 @@
import Foundation
import OSLog
enum LogExporter {
/// Collects recent app logs from the unified logging system.
/// - Parameter hours: How many hours back to look (default 1).
/// - Returns: A formatted string of log entries, newest last.
static func collectLogs(hours: Double = 1) throws -> String {
let store = try OSLogStore(scope: .currentProcessIdentifier)
let cutoff = store.position(date: Date().addingTimeInterval(-hours * 3600))
let subsystem = Bundle.main.bundleIdentifier ?? "hellbender"
let entries = try store.getEntries(at: cutoff, matching: NSPredicate(format: "subsystem == %@", subsystem))
var lines: [String] = []
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
for entry in entries {
guard let logEntry = entry as? OSLogEntryLog else { continue }
let timestamp = formatter.string(from: logEntry.date)
let level = levelString(logEntry.level)
lines.append("[\(timestamp)] [\(level)] [\(logEntry.category)] \(logEntry.composedMessage)")
}
if lines.isEmpty {
return "No log entries found in the last \(Int(hours)) hour(s)."
}
let header = "Hellbender Logs — Exported \(formatter.string(from: Date()))\n"
+ "Entries: \(lines.count) (last \(Int(hours))h)\n"
+ String(repeating: "", count: 60) + "\n"
return header + lines.joined(separator: "\n")
}
private static func levelString(_ level: OSLogEntryLog.Level) -> String {
switch level {
case .debug: "DEBUG"
case .info: "INFO"
case .notice: "NOTICE"
case .error: "ERROR"
case .fault: "FAULT"
default: "UNKNOWN"
}
}
}

View File

@ -2,10 +2,12 @@ import Foundation
import Observation
@Observable
@MainActor
final class AddressListViewModel {
var receiveAddresses: [AddressItem] = []
var changeAddresses: [AddressItem] = []
var selectedTab: AddressTab = .receive
private var expectedProfileId: UUID?
enum AddressTab: String, CaseIterable {
case receive = "Receive"
@ -20,7 +22,13 @@ final class AddressListViewModel {
BitcoinService.shared
}
func loadAddresses() {
func loadAddresses(for profileId: UUID) {
expectedProfileId = profileId
guard bitcoinService.currentProfile?.id == expectedProfileId else {
receiveAddresses = []
changeAddresses = []
return
}
receiveAddresses = bitcoinService.getAddresses(keychain: .external)
changeAddresses = bitcoinService.getAddresses(keychain: .internal)
}

View File

@ -0,0 +1,274 @@
import CryptoKit
import Foundation
import LocalAuthentication
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "AppLock")
@Observable
@MainActor
final class AppLockViewModel {
var isLocked = true
var needsPINEntry = false
var isAuthenticating = false
var pinInput = ""
var pinError = ""
private(set) var failedAttempts: Int = 0
private(set) var lockoutExpiry: Date?
private var backgroundTime: Date?
// MARK: - Computed
var isLockedOut: Bool {
guard let expiry = lockoutExpiry else { return false }
return Date() < expiry
}
var lockoutRemainingText: String {
guard let expiry = lockoutExpiry else { return "" }
let remaining = expiry.timeIntervalSinceNow
guard remaining > 0 else { return "" }
if remaining > 3600 {
let hours = Int(remaining / 3600)
let minutes = Int((remaining.truncatingRemainder(dividingBy: 3600)) / 60)
return "Try again in \(hours)h \(minutes)m"
} else if remaining > 60 {
return "Try again in \(Int(remaining / 60))m"
} else {
return "Try again in \(Int(remaining))s"
}
}
private(set) var hasPIN: Bool = false
private(set) var storedPINLength: Int = 6
// MARK: - Init
init() {
hasPIN = KeychainHelper.load(forKey: Constants.keychainPINHashKey) != nil
if let data = KeychainHelper.load(forKey: Constants.keychainPINLengthKey),
let str = String(data: data, encoding: .utf8),
let len = Int(str)
{
storedPINLength = len
}
loadPersistedState()
}
// MARK: - Authentication
func authenticate() {
guard !isAuthenticating else { return }
isAuthenticating = true
logger.info("Starting biometric authentication")
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
logger.warning("Biometric policy unavailable: \(error?.localizedDescription ?? "unknown", privacy: .public)")
isLocked = false
isAuthenticating = false
return
}
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock \(Constants.appName)") { success, _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if success {
if hasPIN {
logger.info("Biometric success, requesting PIN")
needsPINEntry = true
} else {
logger.info("Biometric success, app unlocked")
isLocked = false
}
} else {
logger.info("Biometric authentication declined")
}
isAuthenticating = false
}
}
}
// MARK: - PIN Management
func verifyPIN(_ pin: String) -> Bool {
if isLockedOut {
pinError = lockoutRemainingText
pinInput = ""
return false
}
guard let storedHash = KeychainHelper.load(forKey: Constants.keychainPINHashKey) else {
return false
}
let inputHash = hashPIN(pin)
if inputHash == storedHash {
logger.info("PIN verified successfully, app unlocked")
failedAttempts = 0
persistFailedAttempts()
lockoutExpiry = nil
persistLockoutExpiry()
pinError = ""
needsPINEntry = false
isLocked = false
return true
} else {
failedAttempts += 1
persistFailedAttempts()
applyLockout()
pinInput = ""
let attempts = failedAttempts
logger.warning("PIN verification failed (attempt \(attempts)/10)")
if failedAttempts >= 10 {
pinError = "Too many attempts"
} else if isLockedOut {
pinError = lockoutRemainingText
} else {
pinError = "Incorrect PIN (\(failedAttempts)/10)"
}
return false
}
}
func setPIN(_ pin: String) {
logger.info("PIN set (\(pin.count) digits)")
let hash = hashPIN(pin)
KeychainHelper.save(hash, forKey: Constants.keychainPINHashKey)
KeychainHelper.save(Data("\(pin.count)".utf8), forKey: Constants.keychainPINLengthKey)
failedAttempts = 0
persistFailedAttempts()
lockoutExpiry = nil
persistLockoutExpiry()
hasPIN = true
storedPINLength = pin.count
}
func removePIN() {
logger.info("PIN removed")
KeychainHelper.delete(forKey: Constants.keychainPINHashKey)
KeychainHelper.delete(forKey: Constants.keychainPINLengthKey)
KeychainHelper.delete(forKey: Constants.keychainFailedAttemptsKey)
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
failedAttempts = 0
lockoutExpiry = nil
hasPIN = false
storedPINLength = 6
}
// MARK: - Background / Foreground
func handleBackground(at date: Date = Date()) {
if backgroundTime == nil {
logger.info("App entering background")
backgroundTime = date
}
}
func handleForeground(timeout: Int) {
if let bgTime = backgroundTime {
let elapsed = Int(Date().timeIntervalSince(bgTime))
if elapsed >= timeout {
logger.info("Inactivity timeout exceeded (\(elapsed)s >= \(timeout)s), re-locking")
isLocked = true
needsPINEntry = false
pinInput = ""
pinError = ""
authenticate()
} else {
logger.info("App returning to foreground (\(elapsed)s < \(timeout)s timeout)")
}
}
backgroundTime = nil
}
// MARK: - Data Wipe
func wipeAllData(modelContext: ModelContext) {
let attempts = failedAttempts
logger.critical("Wiping all data after \(attempts) failed PIN attempts")
// Delete SwiftData records
try? modelContext.delete(model: WalletProfile.self)
try? modelContext.delete(model: CosignerInfo.self)
try? modelContext.delete(model: WalletLabel.self)
try? modelContext.delete(model: FrozenUTXO.self)
try? modelContext.delete(model: SavedPSBT.self)
try? modelContext.save()
// Delete wallet files
try? FileManager.default.removeItem(at: Constants.walletsDirectory())
// Clear UserDefaults
if let bundleID = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleID)
}
// Clear Keychain
KeychainHelper.deleteAll()
// Reset BitcoinService
BitcoinService.shared.unloadWallet()
// Unlock wiped state will show setup wizard
isLocked = false
needsPINEntry = false
failedAttempts = 0
lockoutExpiry = nil
}
// MARK: - Private
private func hashPIN(_ pin: String) -> Data {
let digest = SHA256.hash(data: Data(pin.utf8))
return Data(digest)
}
private func applyLockout() {
let delay: TimeInterval? = switch failedAttempts {
case 1 ... 3: nil
case 4: 60
case 5: 600
case 6: 5400
case 7 ... 9: 86400
default: nil // 10+ handled by wipe
}
if let delay {
let attempts = failedAttempts
logger.warning("Lockout applied: \(Int(delay))s after \(attempts) failed attempts")
lockoutExpiry = Date().addingTimeInterval(delay)
persistLockoutExpiry()
}
}
private func loadPersistedState() {
if let data = KeychainHelper.load(forKey: Constants.keychainFailedAttemptsKey),
let str = String(data: data, encoding: .utf8),
let count = Int(str)
{
failedAttempts = count
}
if let data = KeychainHelper.load(forKey: Constants.keychainLockoutExpiryKey),
let str = String(data: data, encoding: .utf8),
let interval = Double(str)
{
let date = Date(timeIntervalSince1970: interval)
lockoutExpiry = date > Date() ? date : nil
}
}
private func persistFailedAttempts() {
KeychainHelper.save(Data("\(failedAttempts)".utf8), forKey: Constants.keychainFailedAttemptsKey)
}
private func persistLockoutExpiry() {
if let expiry = lockoutExpiry {
KeychainHelper.save(Data("\(expiry.timeIntervalSince1970)".utf8), forKey: Constants.keychainLockoutExpiryKey)
} else {
KeychainHelper.delete(forKey: Constants.keychainLockoutExpiryKey)
}
}
}

View File

@ -1,9 +1,13 @@
import Foundation
import Observation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BumpFeeViewModel")
@Observable
final class BumpFeeViewModel: Identifiable {
@MainActor
final class BumpFeeViewModel: Identifiable, PSBTFlowManaging {
let id = UUID()
enum Step {
case feeInput
@ -52,6 +56,20 @@ final class BumpFeeViewModel: Identifiable {
private let bitcoinService: any BitcoinServiceProtocol
// MARK: - PSBTFlowManaging
var psbtBitcoinService: any BitcoinServiceProtocol {
bitcoinService
}
func navigateAfterSign() {
if needsMoreSignatures {
currentStep = .psbtDisplay
} else {
currentStep = .broadcast
}
}
var feeRateValue: Double {
Double(newFeeRate) ?? 0
}
@ -69,53 +87,38 @@ final class BumpFeeViewModel: Identifiable {
return true
}
static func formatRate(_ rate: Double) -> String {
var s = String(format: "%.2f", rate)
if s.contains(".") {
while s.hasSuffix("0") {
s.removeLast()
}
if s.hasSuffix(".") { s.removeLast() }
}
return s
}
func applyPreset(_ preset: FeePreset) {
selectedFeePreset = preset
if let fees = recommendedFees, let rate = preset.rate(from: fees) {
newFeeRate = Self.formatRate(rate)
newFeeRate = formatFeeRate(rate)
}
// For .custom, preserve the existing newFeeRate value
}
var needsMoreSignatures: Bool {
signaturesCollected < requiredSignatures
}
// needsMoreSignatures and signatureProgress provided by PSBTFlowManaging
var signatureProgress: String {
"\(signaturesCollected) of \(requiredSignatures) signatures"
}
init(transaction: TransactionItem, bitcoinService: any BitcoinServiceProtocol = BitcoinService.shared) {
init(transaction: TransactionItem, bitcoinService: (any BitcoinServiceProtocol)? = nil) {
let service = bitcoinService ?? BitcoinService.shared
originalTxid = transaction.id
originalFee = transaction.fee ?? 0
originalFeeRate = transaction.currentFeeRate
self.bitcoinService = bitcoinService
requiredSignatures = bitcoinService.requiredSignatures
totalCosigners = bitcoinService.totalCosigners
self.bitcoinService = service
requiredSignatures = service.requiredSignatures
totalCosigners = service.totalCosigners
// Pre-fill custom with the minimum valid bump rate
let minRate = (originalFeeRate.map { Double($0) } ?? 0.0) + 1.0
newFeeRate = BumpFeeViewModel.formatRate(max(minRate, 1.0))
newFeeRate = formatFeeRate(max(minRate, 1.0))
}
/// Initialize from a saved RBF PSBT to resume signing
init(savedPSBT: SavedPSBT, bitcoinService: any BitcoinServiceProtocol = BitcoinService.shared) {
init(savedPSBT: SavedPSBT, bitcoinService: (any BitcoinServiceProtocol)? = nil) {
let service = bitcoinService ?? BitcoinService.shared
originalTxid = savedPSBT.originalTxid ?? ""
originalFee = 0
originalFeeRate = nil
self.bitcoinService = bitcoinService
self.bitcoinService = service
requiredSignatures = savedPSBT.requiredSignatures
totalCosigners = bitcoinService.totalCosigners
totalCosigners = service.totalCosigners
newFeeRate = savedPSBT.feeRateSatVb
psbtBytes = savedPSBT.psbtBytes
psbtBase64 = savedPSBT.psbtBase64
@ -126,7 +129,7 @@ final class BumpFeeViewModel: Identifiable {
savedPSBTId = savedPSBT.id
savedPSBTName = savedPSBT.name
if let signerInfo = bitcoinService.psbtSignerInfo(savedPSBT.psbtBytes) {
if let signerInfo = service.psbtSignerInfo(savedPSBT.psbtBytes) {
signaturesCollected = signerInfo.totalSignatures
signerStatus = signerInfo.cosignerSignStatus
} else {
@ -143,7 +146,7 @@ final class BumpFeeViewModel: Identifiable {
self.recommendedFees = rates
}
} catch {
print("Failed to fetch fee rates: \(error)")
logger.error("Failed to fetch fee rates: \(error)")
}
}
@ -176,40 +179,7 @@ final class BumpFeeViewModel: Identifiable {
isProcessing = false
}
func handleSignedPSBT(_ signedBytes: Data, modelContext: ModelContext? = nil) async {
isProcessing = true
do {
let previousBytes = psbtBytes
let (updatedBase64, updatedBytes) = try await bitcoinService.combinePSBTs(
original: psbtBytes,
signed: signedBytes
)
psbtBase64 = updatedBase64
psbtBytes = updatedBytes
// Use PSBT introspection to determine signature count
if let signerInfo = bitcoinService.psbtSignerInfo(updatedBytes) {
signaturesCollected = signerInfo.totalSignatures
signerStatus = signerInfo.cosignerSignStatus
} else if updatedBytes != previousBytes {
signaturesCollected += 1
}
// Auto-save after each new signature, matching normal send flow
if updatedBytes != previousBytes, let context = modelContext {
autoSavePSBT(context: context)
}
if needsMoreSignatures {
currentStep = .psbtDisplay
} else {
currentStep = .broadcast
}
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
// handleSignedPSBT provided by PSBTFlowManaging default implementation
func finalizeTx() {
do {
@ -246,13 +216,7 @@ final class BumpFeeViewModel: Identifiable {
return "Bump Fee " + formatter.string(from: Date())
}
func autoSavePSBT(context: ModelContext) {
if savedPSBTId != nil {
savePSBT(name: savedPSBTName.isEmpty ? defaultPSBTName() : savedPSBTName, context: context)
} else if totalCosigners > 1 {
savePSBT(name: defaultPSBTName(), context: context)
}
}
// autoSavePSBT provided by PSBTFlowManaging default implementation
func savePSBT(name: String, context: ModelContext) {
guard let walletID = BitcoinService.shared.currentProfile?.id else { return }
@ -274,7 +238,12 @@ final class BumpFeeViewModel: Identifiable {
existing.changeAddress = changeAddress
existing.inputCount = inputCount
existing.inputOutpoints = outpoints
try? context.save()
do {
try context.save()
} catch {
logger.error("Failed to update saved PSBT: \(error)")
errorMessage = "Failed to save PSBT: \(error.localizedDescription)"
}
return
}
}
@ -298,18 +267,15 @@ final class BumpFeeViewModel: Identifiable {
)
saved.originalTxid = originalTxid
context.insert(saved)
try? context.save()
savedPSBTId = saved.id
savedPSBTName = trimmedName
do {
try context.save()
savedPSBTId = saved.id
savedPSBTName = trimmedName
} catch {
logger.error("Failed to save new PSBT: \(error)")
errorMessage = "Failed to save PSBT: \(error.localizedDescription)"
}
}
func deleteSavedPSBT(context: ModelContext) {
guard let existingId = savedPSBTId else { return }
let descriptor = FetchDescriptor<SavedPSBT>(predicate: #Predicate { $0.id == existingId })
if let existing = try? context.fetch(descriptor).first {
context.delete(existing)
try? context.save()
}
savedPSBTId = nil
}
// deleteSavedPSBT provided by PSBTFlowManaging default implementation
}

View File

@ -0,0 +1,115 @@
import Foundation
import SwiftData
/// Format a fee rate for display, stripping unnecessary trailing zeros.
func formatFeeRate(_ rate: Double) -> String {
var s = String(format: "%.2f", rate)
if s.contains(".") {
while s.hasSuffix("0") {
s.removeLast()
}
if s.hasSuffix(".") { s.removeLast() }
}
return s
}
/// Shared PSBT workflow operations used by both SendViewModel and BumpFeeViewModel.
/// Eliminates duplicated code for signature handling, PSBT saving, and deletion.
@MainActor
protocol PSBTFlowManaging: AnyObject {
// MARK: - Shared PSBT State
var psbtBase64: String { get set }
var psbtBytes: Data { get set }
var signaturesCollected: Int { get set }
var requiredSignatures: Int { get set }
var signerStatus: [(label: String, fingerprint: String, hasSigned: Bool)] { get set }
var errorMessage: String? { get set }
var isProcessing: Bool { get set }
// MARK: - Saved PSBT State
var savedPSBTId: UUID? { get set }
var savedPSBTName: String { get set }
var totalCosigners: Int { get set }
// MARK: - Dependencies
var psbtBitcoinService: any BitcoinServiceProtocol { get }
// MARK: - Customization Points
/// Navigate to the appropriate step after processing a signed PSBT.
func navigateAfterSign()
/// Generate a default name for saved PSBTs.
func defaultPSBTName() -> String
/// Save the current PSBT state to SwiftData.
/// Each ViewModel provides its own implementation since the SavedPSBT fields differ.
func savePSBT(name: String, context: ModelContext)
}
// MARK: - Default Implementations
extension PSBTFlowManaging {
var needsMoreSignatures: Bool {
signaturesCollected < requiredSignatures
}
var signatureProgress: String {
"\(signaturesCollected) of \(requiredSignatures) signatures"
}
/// Combine a signed PSBT with the current one, update signature status, auto-save, and navigate.
func handleSignedPSBT(_ signedBytes: Data, modelContext: ModelContext? = nil) async {
isProcessing = true
do {
let previousBytes = psbtBytes
let (updatedBase64, updatedBytes) = try await psbtBitcoinService.combinePSBTs(
original: psbtBytes,
signed: signedBytes
)
psbtBase64 = updatedBase64
psbtBytes = updatedBytes
// Use PSBT introspection to determine signer status
if let signerInfo = psbtBitcoinService.psbtSignerInfo(updatedBytes) {
signaturesCollected = signerInfo.totalSignatures
signerStatus = signerInfo.cosignerSignStatus
} else if updatedBytes != previousBytes {
// Fallback: increment count based on byte change
signaturesCollected += 1
}
if updatedBytes != previousBytes, let context = modelContext {
autoSavePSBT(context: context)
}
navigateAfterSign()
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
/// Auto-save the PSBT if one already exists or if this is a multisig wallet.
func autoSavePSBT(context: ModelContext) {
if savedPSBTId != nil {
savePSBT(name: savedPSBTName.isEmpty ? defaultPSBTName() : savedPSBTName, context: context)
} else if totalCosigners > 1 {
savePSBT(name: defaultPSBTName(), context: context)
}
}
/// Delete the saved PSBT from SwiftData.
func deleteSavedPSBT(context: ModelContext) {
guard let existingId = savedPSBTId else { return }
let descriptor = FetchDescriptor<SavedPSBT>(predicate: #Predicate { $0.id == existingId })
if let existing = try? context.fetch(descriptor).first {
context.delete(existing)
try? context.save()
}
savedPSBTId = nil
}
}

View File

@ -2,16 +2,23 @@ import Foundation
import Observation
@Observable
@MainActor
final class ReceiveViewModel {
var currentAddress: String = ""
var addressIndex: UInt32 = 0
var errorMessage: String?
private var expectedProfileId: UUID?
private var bitcoinService: BitcoinService {
BitcoinService.shared
}
func loadAddress() {
func loadAddress(for profileId: UUID) {
expectedProfileId = profileId
guard bitcoinService.currentProfile?.id == expectedProfileId else {
currentAddress = ""
return
}
do {
let (address, index) = try bitcoinService.getNextAddress()
currentAddress = address
@ -22,6 +29,11 @@ final class ReceiveViewModel {
}
func generateNewAddress() {
guard bitcoinService.currentProfile?.id == expectedProfileId else {
currentAddress = ""
errorMessage = "Wallet changed — please reload"
return
}
do {
let (address, index) = try bitcoinService.revealNextAddress()
currentAddress = address

View File

@ -1,83 +1,13 @@
import Foundation
import Observation
import OSLog
import SwiftData
struct Recipient: Identifiable {
let id = UUID()
var address: String = ""
var amountSats: String = ""
var isSendMax: Bool = false
var label: String = ""
var amountValue: UInt64? {
UInt64(amountSats)
}
var isAddressEmpty: Bool {
address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var isValidAddress: Bool {
!isAddressEmpty
}
/// Checks if the address looks like a valid Bitcoin address format
func isAddressFormatValid(network: BitcoinNetwork?) -> Bool {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return true } // empty is not "invalid format", just missing
guard let network else { return true }
let prefix = network.addressPrefix
// Accept bech32/bech32m addresses for the current network
if trimmed.lowercased().hasPrefix(prefix) {
return trimmed.count >= prefix.count + 10 // minimum reasonable length
}
// Also accept legacy P2SH (3...) and P2PKH (1...) on mainnet
if network == .mainnet, trimmed.hasPrefix("3") || trimmed.hasPrefix("1") {
return trimmed.count >= 26 && trimmed.count <= 35
}
// Accept testnet P2SH (2...) and P2PKH (m.../n...)
if network != .mainnet, trimmed.hasPrefix("2") || trimmed.hasPrefix("m") || trimmed.hasPrefix("n") {
return trimmed.count >= 26 && trimmed.count <= 35
}
return false
}
var isValidAmount: Bool {
guard let amount = amountValue else { return false }
return amount > 0
}
var isAmountEmpty: Bool {
amountSats.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
enum FeePreset: CaseIterable {
case fast, medium, slow, custom
var displayName: String {
switch self {
case .fast: "Fast"
case .medium: "Medium"
case .slow: "Slow"
case .custom: "Custom"
}
}
func rate(from fees: BitcoinService.RecommendedFees?) -> Double? {
guard let fees else { return nil }
switch self {
case .fast: return fees.fast
case .medium: return fees.medium
case .slow: return fees.slow
case .custom: return nil
}
}
}
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "SendViewModel")
@Observable
final class SendViewModel {
@MainActor
final class SendViewModel: PSBTFlowManaging {
enum Step: Int, CaseIterable {
case recipients
case review
@ -144,8 +74,22 @@ final class SendViewModel {
private let bitcoinService: any BitcoinServiceProtocol
init(bitcoinService: any BitcoinServiceProtocol = BitcoinService.shared) {
self.bitcoinService = bitcoinService
// 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> = []
@ -264,13 +208,7 @@ final class SendViewModel {
totalSendAmount + totalFee + (changeAmount ?? 0)
}
var signatureProgress: String {
"\(signaturesCollected) of \(requiredSignatures) signatures"
}
var needsMoreSignatures: Bool {
signaturesCollected < requiredSignatures
}
// signatureProgress and needsMoreSignatures provided by PSBTFlowManaging
func loadBalance() {
availableBalance = spendableUTXOs.reduce(0) { $0 + $1.amount }
@ -410,28 +348,17 @@ final class SendViewModel {
applyPreset(selectedFeePreset)
}
} catch {
print("Failed to fetch fee rates: \(error)")
logger.error("Failed to fetch fee rates: \(error)")
}
}
func applyPreset(_ preset: FeePreset) {
selectedFeePreset = preset
if let rate = preset.rate(from: recommendedFees) {
feeRateSatVb = formatRate(rate)
feeRateSatVb = formatFeeRate(rate)
}
}
private func formatRate(_ rate: Double) -> String {
var s = String(format: "%.2f", rate)
if s.contains(".") {
while s.hasSuffix("0") {
s.removeLast()
}
if s.hasSuffix(".") { s.removeLast() }
}
return s
}
/// Recalculate the max amount for whichever recipient has isSendMax
func recalculateMaxIfNeeded() {
guard let index = recipients.firstIndex(where: { $0.isSendMax }) else { return }
@ -470,81 +397,49 @@ final class SendViewModel {
/// Parse a BIP-21 URI or plain address string
func parseBIP21(_ input: String, forRecipientAt index: Int) {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
// Check for BIP-21 URI: bitcoin:address?amount=0.001&label=...
guard let url = URL(string: trimmed),
let scheme = url.scheme?.lowercased(),
scheme == "bitcoin" || scheme == "BITCOIN".lowercased()
else {
// Plain address
recipients[index].address = trimmed
return
}
// Extract address from path
let address: String
if let host = url.host(percentEncoded: false), !host.isEmpty {
address = host
} else {
// bitcoin:tb1q... opaque path
let stripped = trimmed.drop(while: { $0 != ":" }).dropFirst()
let addrPart = stripped.prefix(while: { $0 != "?" })
address = String(addrPart)
}
recipients[index].address = address
// Parse query parameters
if let components = URLComponents(string: trimmed) {
for item in components.queryItems ?? [] {
switch item.name.lowercased() {
case "amount":
// BIP-21 amount is in BTC, convert to sats
if let btcString = item.value, let btc = Double(btcString) {
let sats = UInt64(btc * 100_000_000)
recipients[index].amountSats = "\(sats)"
recipients[index].isSendMax = false
}
default:
break
}
}
}
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 recipientList = recipients.map { r in
(address: r.address.trimmingCharacters(in: .whitespacesAndNewlines),
amount: r.amountValue ?? 0,
isSendMax: r.isSendMax)
}
let utxoOutpoints: [(txid: String, vout: UInt32)]? = manualUTXOSelection
? allUTXOs.filter { selectedUTXOIds.contains($0.id) }.map { (txid: $0.txid, vout: $0.vout) }
: nil
let result = try await bitcoinService.createPSBT(
recipients: recipientList,
feeRate: feeRateValue,
utxos: utxoOutpoints,
utxos: selectedUTXOOutpoints,
unspendable: frozenOutpoints
)
psbtBase64 = result.base64
psbtBytes = result.bytes
totalFee = result.fee
changeAmount = result.changeAmount
changeAddress = result.changeAddress
inputCount = result.inputCount
// Initialize cosigner signing status (all unsigned)
if let signerInfo = bitcoinService.psbtSignerInfo(result.bytes) {
signerStatus = signerInfo.cosignerSignStatus
}
applyPSBTResult(result)
currentStep = .review
} catch {
errorMessage = error.localizedDescription
@ -553,24 +448,13 @@ final class SendViewModel {
}
func createPSBT() async {
let recipientList = recipients.map { r in
(address: r.address.trimmingCharacters(in: .whitespacesAndNewlines),
amount: r.amountValue ?? 0,
isSendMax: r.isSendMax)
}
guard recipientList.allSatisfy({ !$0.address.isEmpty && ($0.amount > 0 || $0.isSendMax) }) else {
errorMessage = "Invalid recipient or amount"
return
}
let utxoOutpoints: [(txid: String, vout: UInt32)]? = manualUTXOSelection
? allUTXOs.filter { selectedUTXOIds.contains($0.id) }.map { (txid: $0.txid, vout: $0.vout) }
: nil
// For manual selection, guard against spending a UTXO the user has frozen.
// (BDK lets addUtxos() override the unspendable list, so this stays at app layer.)
if let validationError = validateUTXOInputs(outpoints: utxoOutpoints) {
if let validationError = validateUTXOInputs(outpoints: selectedUTXOOutpoints) {
errorMessage = validationError
return
}
@ -580,20 +464,11 @@ final class SendViewModel {
let result = try await bitcoinService.createPSBT(
recipients: recipientList,
feeRate: feeRateValue,
utxos: utxoOutpoints,
utxos: selectedUTXOOutpoints,
unspendable: frozenOutpoints
)
psbtBase64 = result.base64
psbtBytes = result.bytes
totalFee = result.fee
changeAmount = result.changeAmount
changeAddress = result.changeAddress
inputCount = result.inputCount
applyPSBTResult(result)
signaturesCollected = 0
// Initialize cosigner signing status (all unsigned)
if let signerInfo = bitcoinService.psbtSignerInfo(result.bytes) {
signerStatus = signerInfo.cosignerSignStatus
}
currentStep = .psbtDisplay
} catch {
errorMessage = error.localizedDescription
@ -601,41 +476,7 @@ final class SendViewModel {
isProcessing = false
}
func handleSignedPSBT(_ signedBytes: Data, modelContext: ModelContext? = nil) async {
isProcessing = true
do {
let previousBytes = psbtBytes
let (updatedBase64, updatedBytes) = try await bitcoinService.combinePSBTs(
original: psbtBytes,
signed: signedBytes
)
psbtBase64 = updatedBase64
psbtBytes = updatedBytes
// Use PSBT introspection to determine signer status
if let signerInfo = bitcoinService.psbtSignerInfo(updatedBytes) {
signaturesCollected = signerInfo.totalSignatures
signerStatus = signerInfo.cosignerSignStatus
} else if updatedBytes != previousBytes {
// Fallback: if psbtSignerInfo unavailable (e.g. no bip32_derivation),
// increment count based on byte change
signaturesCollected += 1
}
if updatedBytes != previousBytes, let context = modelContext {
autoSavePSBT(context: context)
}
if needsMoreSignatures {
currentStep = .psbtDisplay
} else {
currentStep = .broadcast
}
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
// handleSignedPSBT provided by PSBTFlowManaging default implementation
func finalizeTx() {
guard finalizedTxBytes.isEmpty else { return }
@ -651,10 +492,6 @@ final class SendViewModel {
do {
let txid = try await bitcoinService.broadcastPSBT(psbtBytes)
broadcastTxid = txid
// Trigger sync after broadcast to update wallet state
Task {
try? await bitcoinService.sync()
}
} catch {
errorMessage = error.localizedDescription
}
@ -705,7 +542,12 @@ final class SendViewModel {
existing.manualUTXOSelection = manualUTXOSelection
existing.selectedUTXOIds = utxoIdsString
existing.inputOutpoints = outpoints
try? context.save()
do {
try context.save()
} catch {
logger.error("Failed to update saved PSBT: \(error)")
errorMessage = "Failed to save PSBT: \(error.localizedDescription)"
}
return
}
}
@ -728,31 +570,20 @@ final class SendViewModel {
inputOutpoints: outpoints
)
context.insert(saved)
try? context.save()
savedPSBTId = saved.id
}
func autoSavePSBT(context: ModelContext) {
if savedPSBTId != nil {
// Always update an existing saved PSBT (e.g. after adding a signature)
savePSBT(name: savedPSBTName.isEmpty ? defaultPSBTName() : savedPSBTName, context: context)
} else if totalCosigners > 1 {
// Auto-create for any multisig wallet (including 1-of-N where M=1 but N>1)
savePSBT(name: defaultPSBTName(), context: context)
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) {
// Restore recipients
if let decoded = try? JSONDecoder().decode([SavedRecipient].self, from: saved.recipientsJSON) {
recipients = decoded.map { sr in
var r = Recipient()
r.address = sr.address
r.amountSats = sr.amountSats
r.isSendMax = sr.isSendMax
r.label = sr.label
return r
}
recipients = decoded.map(Recipient.init(from:))
}
feeRateSatVb = saved.feeRateSatVb
@ -782,15 +613,7 @@ final class SendViewModel {
currentStep = .review
}
func deleteSavedPSBT(context: ModelContext) {
guard let existingId = savedPSBTId else { return }
let descriptor = FetchDescriptor<SavedPSBT>(predicate: #Predicate { $0.id == existingId })
if let existing = try? context.fetch(descriptor).first {
context.delete(existing)
try? context.save()
}
savedPSBTId = nil
}
// deleteSavedPSBT provided by PSBTFlowManaging default implementation
// MARK: - Import PSBT
@ -798,15 +621,7 @@ final class SendViewModel {
do {
let result = try bitcoinService.validateAndParseImportedPSBT(psbtData, frozenOutpoints: frozenOutpoints)
// Populate view model state from parsed PSBT
recipients = result.recipients.map { sr in
var r = Recipient()
r.address = sr.address
r.amountSats = sr.amountSats
r.isSendMax = sr.isSendMax
r.label = sr.label
return r
}
recipients = result.recipients.map(Recipient.init(from:))
feeRateSatVb = result.feeRateSatVb
totalFee = result.fee
@ -856,8 +671,9 @@ final class SendViewModel {
recipients = [Recipient()]
amountInFiat = false
fiatDisplayAmount.removeAll()
feeRateSatVb = ""
recommendedFees = nil
selectedFeePreset = .medium
applyPreset(.medium)
psbtBase64 = ""
psbtBytes = Data()
totalFee = 0

View File

@ -1,8 +1,10 @@
import BitcoinDevKit
import Foundation
import Observation
import SwiftData
@Observable
@MainActor
final class SetupWizardViewModel {
enum Step: Int, CaseIterable {
case welcome
@ -57,10 +59,10 @@ final class SetupWizardViewModel {
let hasTestnetKeys = text.contains("tpub") || text.contains("Vpub")
let hasMainnetKeys = text.contains("xpub") || text.contains("Zpub")
if network == .mainnet && hasTestnetKeys && !hasMainnetKeys {
if network == .mainnet, hasTestnetKeys, !hasMainnetKeys {
return "Testnet descriptors cannot be used on mainnet"
}
if network != .mainnet && hasMainnetKeys && !hasTestnetKeys {
if network != .mainnet, hasMainnetKeys, !hasTestnetKeys {
return "Mainnet descriptors cannot be used on testnet/signet"
}
return nil
@ -80,12 +82,15 @@ final class SetupWizardViewModel {
// 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 : 4
creationMode == .createNew ? 5 : 3
}
var currentStepIndex: Int {
@ -212,7 +217,13 @@ final class SetupWizardViewModel {
}
func parseImportedDescriptor() -> Bool {
var text = importedDescriptorText.trimmingCharacters(in: .whitespacesAndNewlines)
// 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
@ -223,6 +234,11 @@ final class SetupWizardViewModel {
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: "']")
@ -252,15 +268,38 @@ final class SetupWizardViewModel {
} 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 {
// Standard single-path descriptor
let isExternal = text.contains("/0/*")
if isExternal {
externalDescriptor = text
internalDescriptor = text.replacingOccurrences(of: "/0/*", with: "/1/*")
// 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
externalDescriptor = text.replacingOccurrences(of: "/1/*", with: "/0/*")
}
}
@ -331,13 +370,26 @@ final class SetupWizardViewModel {
buildDescriptors()
currentStep = .walletName
case .descriptorImport:
importDescriptorError = nil
guard isElectrumHostValid else {
errorMessage = "An Electrum server host is required for \(network.displayName)"
importDescriptorError = "An Electrum server host is required for \(network.displayName)"
return
}
if parseImportedDescriptor() {
currentStep = .walletName
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:
@ -353,7 +405,10 @@ final class SetupWizardViewModel {
case .cosignerImport: currentStep = .multisigConfig
case .descriptorImport: currentStep = .creationChoice
case .walletName:
currentStep = creationMode == .createNew ? .cosignerImport : .descriptorImport
if creationMode == .createNew {
currentStep = .cosignerImport
}
// Import flow: back button is hidden, wallet already created
case .review: currentStep = .walletName
}
}
@ -365,6 +420,15 @@ final class SetupWizardViewModel {
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)
@ -403,10 +467,17 @@ final class SetupWizardViewModel {
modelContext.insert(cosigner)
}
try modelContext.save()
// Store active wallet ID
// 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
}
}
}

View File

@ -2,6 +2,7 @@ import Foundation
import Observation
@Observable
@MainActor
final class TransactionListViewModel {
var transactions: [TransactionItem] = []
var isLoading = false
@ -9,6 +10,7 @@ final class TransactionListViewModel {
var walletName: String = ""
var network: BitcoinNetwork = .testnet4
var multisigDescription: String = ""
private var expectedProfileId: UUID?
private var bitcoinService: BitcoinService {
BitcoinService.shared
@ -20,6 +22,7 @@ final class TransactionListViewModel {
func loadActiveWallet(from wallets: [WalletProfile]) {
guard let active = wallets.first(where: { $0.isActive }) else { return }
expectedProfileId = active.id
walletName = active.name
network = active.bitcoinNetwork
multisigDescription = active.multisigDescription
@ -27,24 +30,25 @@ final class TransactionListViewModel {
let alreadyLoaded = bitcoinService.currentProfile?.id == active.id && bitcoinService.wallet != nil
if alreadyLoaded {
updateFromService()
} else {
} else if !bitcoinService.syncState.isSyncing {
// Cold start: BitcoinService has no wallet loaded yet
isLoading = true
Task {
await loadWallet(active)
do {
try await bitcoinService.loadWallet(profile: active)
updateFromService()
try await bitcoinService.sync()
updateFromService()
} catch {
// syncState is managed by BitcoinService
}
isLoading = false
}
}
}
func loadWallet(_ profile: WalletProfile) async {
do {
try await bitcoinService.loadWallet(profile: profile)
updateFromService()
await refresh()
} catch {
// syncState is managed by BitcoinService
}
}
func updateFromService() {
guard bitcoinService.currentProfile?.id == expectedProfileId else { return }
balance = bitcoinService.balance
transactions = bitcoinService.transactions
}

View File

@ -2,6 +2,7 @@ import Foundation
import Observation
@Observable
@MainActor
final class UTXOListViewModel {
var utxos: [UTXOItem] = []

View File

@ -1,48 +1,86 @@
import Foundation
import Observation
import OSLog
import SwiftData
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "WalletManager")
@Observable
@MainActor
final class WalletManagerViewModel {
var errorMessage: String?
func setActiveWallet(_ wallet: WalletProfile, allWallets: [WalletProfile], modelContext: ModelContext) {
logger.info("Switching active wallet to \(wallet.name, privacy: .private)")
for w in allWallets {
w.isActive = (w.id == wallet.id)
}
UserDefaults.standard.set(wallet.id.uuidString, forKey: Constants.activeWalletIDKey)
do {
try modelContext.save()
Task {
try? await BitcoinService.shared.loadWallet(profile: wallet)
try? await BitcoinService.shared.sync()
// Only update UserDefaults after successful DB save
UserDefaults.standard.set(wallet.id.uuidString, forKey: Constants.activeWalletIDKey)
// Immediately clear stale wallet data so the UI never briefly shows old transactions/addresses
BitcoinService.shared.unloadWallet()
BitcoinService.shared.syncTask = Task {
do {
try await BitcoinService.shared.loadWallet(profile: wallet)
try await BitcoinService.shared.sync()
} catch {
logger.error("Failed to load/sync wallet: \(error)")
}
}
} catch {
logger.error("Failed to save active wallet: \(error)")
errorMessage = error.localizedDescription
}
}
func deleteWallet(_ wallet: WalletProfile, modelContext: ModelContext) {
logger.info("Deleting wallet \(wallet.name, privacy: .private)")
let wasActive = wallet.isActive
let walletID = wallet.id
// Delete associated records that use walletID (not covered by cascade)
do {
let frozenDescriptor = FetchDescriptor<FrozenUTXO>(predicate: #Predicate { $0.walletID == walletID })
for frozen in try modelContext.fetch(frozenDescriptor) {
modelContext.delete(frozen)
}
let labelDescriptor = FetchDescriptor<WalletLabel>(predicate: #Predicate { $0.walletID == walletID })
for label in try modelContext.fetch(labelDescriptor) {
modelContext.delete(label)
}
let psbtDescriptor = FetchDescriptor<SavedPSBT>(predicate: #Predicate { $0.walletID == walletID })
for psbt in try modelContext.fetch(psbtDescriptor) {
modelContext.delete(psbt)
}
} catch {
logger.error("Failed to fetch associated records for deletion: \(error)")
errorMessage = error.localizedDescription
return
}
modelContext.delete(wallet)
// Clean up wallet storage
let walletDir = Constants.walletDirectory(for: walletID)
try? FileManager.default.removeItem(at: walletDir)
// If the active wallet was deleted, activate another one before saving
if wasActive {
let remaining = (try? modelContext.fetch(FetchDescriptor<WalletProfile>())) ?? []
if let next = remaining.first {
next.isActive = true
}
}
// Single atomic save for all deletes + reactivation
do {
try modelContext.save()
// If the active wallet was deleted, activate another one
// Update UserDefaults only after successful save
if wasActive {
let remaining = (try? modelContext.fetch(FetchDescriptor<WalletProfile>())) ?? []
if let next = remaining.first {
next.isActive = true
if let next = remaining.first(where: { $0.isActive }) {
UserDefaults.standard.set(next.id.uuidString, forKey: Constants.activeWalletIDKey)
try modelContext.save()
BitcoinService.shared.unloadWallet()
Task {
try? await BitcoinService.shared.loadWallet(profile: next)
}
@ -50,7 +88,12 @@ final class WalletManagerViewModel {
UserDefaults.standard.removeObject(forKey: Constants.activeWalletIDKey)
}
}
// Clean up wallet storage after successful DB save
let walletDir = Constants.walletDirectory(for: walletID)
try? FileManager.default.removeItem(at: walletDir)
} catch {
logger.error("Failed to save wallet deletion: \(error)")
errorMessage = error.localizedDescription
}
}

View File

@ -1,7 +1,10 @@
import Bbqr
import CoreImage.CIFilterBuiltins
import OSLog
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "BBQRDisplayView")
struct BBQRDisplayView: View {
let data: Data
let fileType: FileType
@ -65,7 +68,7 @@ struct BBQRDisplayView: View {
frames = split.parts()
qrImages = frames.compactMap { generateQRImage(from: $0) }
} catch {
print("BBQRDisplayView: Failed to split data: \(error)")
logger.error("Failed to split BBQR data: \(error)")
}
}

View File

@ -0,0 +1,122 @@
import SwiftUI
enum PINPadMode {
case create
case verify
}
struct PINPadView: View {
let title: String
let subtitle: String
let dotCount: Int
let minDigits: Int
let mode: PINPadMode
@Binding var pin: String
let isDisabled: Bool
let onComplete: (String) -> Void
var onFaceIDTap: (() -> Void)?
var hint: String?
private let columns = Array(repeating: GridItem(.fixed(72), spacing: 24), count: 3)
var body: some View {
VStack(spacing: 16) {
// Title
Text(title)
.font(.hbTitle)
.foregroundStyle(Color.hbTextPrimary)
.frame(height: 30)
// Hint text (e.g. "Choose a PIN between 4 and 8 digits")
Text(hint ?? " ")
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
.frame(height: 18)
.opacity(hint == nil ? 0 : 1)
// Dot indicators
HStack(spacing: 12) {
ForEach(0 ..< dotCount, id: \.self) { index in
Circle()
.fill(index < pin.count ? Color.hbBitcoinOrange : Color.hbBorder)
.frame(width: 14, height: 14)
}
}
.frame(height: 20)
// Status text always occupies space
Text(subtitle)
.font(.hbBody(14))
.foregroundStyle(Color.hbError)
.frame(height: 20)
.opacity(subtitle.isEmpty ? 0 : 1)
// Number pad
LazyVGrid(columns: columns, spacing: 16) {
ForEach(1 ... 9, id: \.self) { digit in
digitButton("\(digit)")
}
// Bottom row: Face ID / empty, 0, backspace
if let onFaceIDTap {
Button(action: onFaceIDTap) {
Image(systemName: "faceid")
.font(.system(size: 24))
.foregroundStyle(Color.hbBitcoinOrange)
.frame(width: 72, height: 72)
}
.disabled(isDisabled)
} else {
Color.clear
.frame(width: 72, height: 72)
}
digitButton("0")
Button {
if !pin.isEmpty {
pin.removeLast()
}
} label: {
Image(systemName: "delete.backward")
.font(.system(size: 22))
.foregroundStyle(Color.hbTextPrimary)
.frame(width: 72, height: 72)
}
.disabled(isDisabled || pin.isEmpty)
}
// Confirm button area fixed height
Group {
if mode == .create, pin.count >= minDigits {
Button(action: { onComplete(pin) }) {
Text("Confirm")
.hbPrimaryButton()
}
.padding(.horizontal, 24)
} else {
Color.clear
}
}
.frame(height: 50)
}
}
private func digitButton(_ digit: String) -> some View {
Button {
guard pin.count < dotCount else { return }
pin += digit
if mode == .verify, pin.count == dotCount {
onComplete(pin)
}
} label: {
Text(digit)
.font(.system(size: 28, weight: .medium, design: .rounded))
.foregroundStyle(Color.hbTextPrimary)
.frame(width: 72, height: 72)
.background(Color.hbSurface)
.clipShape(Circle())
}
.disabled(isDisabled || pin.count >= dotCount)
}
}

View File

@ -1,12 +1,16 @@
import AVFoundation
import Bbqr
import Combine
import OSLog
import SwiftUI
import URKit
import URUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "URScannerSheet")
struct URScannerSheet: View {
let onResult: (AppURResult) -> Void
let preferMacroCamera: Bool
@StateObject private var videoSession: URVideoSession
@StateObject private var scanState: URScanState
@ -20,10 +24,11 @@ struct URScannerSheet: View {
private let codesPublisher: URCodesPublisher
init(onResult: @escaping (AppURResult) -> Void) {
init(preferMacroCamera: Bool = false, onResult: @escaping (AppURResult) -> Void) {
self.onResult = onResult
self.preferMacroCamera = preferMacroCamera
let publisher = URCodesPublisher()
self.codesPublisher = publisher
codesPublisher = publisher
_videoSession = StateObject(wrappedValue: URVideoSession(codesPublisher: publisher))
_scanState = StateObject(wrappedValue: URScanState(codesPublisher: publisher))
}
@ -66,6 +71,9 @@ struct URScannerSheet: View {
}
}
.onAppear {
if preferMacroCamera {
switchToMacroCameraIfAvailable()
}
configureCameraForCloseScanning()
}
.onReceive(scanState.resultPublisher) { result in
@ -78,6 +86,8 @@ struct URScannerSheet: View {
case let .other(code):
if let hdKeyResult = URService.parseTextEncodedXpub(code) {
onResult(hdKeyResult)
} else if let descriptor = URService.extractDescriptorFromJSON(code) {
onResult(.descriptor(descriptor))
} else {
handlePossibleBBQR(code)
}
@ -94,15 +104,47 @@ struct URScannerSheet: View {
return estimatedPercent
}
private func switchToMacroCameraIfAvailable() {
// Discover a virtual multi-camera device (dual-wide or triple) which
// supports automatic macro switching on iPhone 13 Pro+.
let preferredTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
]
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: preferredTypes,
mediaType: .video,
position: .back
)
if let macroDevice = discovery.devices.first {
videoSession.setCaptureDevice(macroDevice)
logger.info("Switched to macro-capable camera: \(macroDevice.localizedName)")
}
}
private func configureCameraForCloseScanning() {
guard let device = videoSession.currentCaptureDevice else { return }
try? device.lockForConfiguration()
if device.isAutoFocusRangeRestrictionSupported {
device.autoFocusRangeRestriction = .near
}
// Continuous autofocus restricted to near range for close-up QR codes
if device.isFocusModeSupported(.continuousAutoFocus) {
device.focusMode = .continuousAutoFocus
}
if device.isAutoFocusRangeRestrictionSupported {
device.autoFocusRangeRestriction = .near
}
// Center the focus point for QR codes held in front of the camera
if device.isFocusPointOfInterestSupported {
device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5)
}
// Geometric distortion correction improves QR readability when the
// ultra-wide lens engages for macro mode (iPhone 13 Pro+)
if device.isGeometricDistortionCorrectionSupported {
device.isGeometricDistortionCorrectionEnabled = true
}
device.unlockForConfiguration()
}
@ -134,7 +176,7 @@ struct URScannerSheet: View {
}
}
} catch {
print("BBQRScan: Error processing part: \(error)")
logger.error("BBQR error processing part: \(error)")
}
}
}

View File

@ -91,6 +91,17 @@ struct ConnectionStatusView: View {
InfoRow(label: "Chain tip", value: "\(service.chainTipHeight)")
}
if let error = service.electrumConnectionError, !service.isElectrumConnected {
VStack(alignment: .leading, spacing: 4) {
Text("Connection Error")
.font(.hbLabel())
.foregroundStyle(Color.hbError)
Text(error)
.font(.hbBody(13))
.foregroundStyle(Color.hbTextPrimary)
}
}
if let result = testResult {
Text(result)
.font(.hbBody(13))
@ -331,10 +342,10 @@ struct ConnectionStatusView: View {
Task {
let config = wallet.electrumConfig
do {
try await service.testElectrumConnection(config: config)
testResult = "Success — server responded to ping"
let height = try await service.testElectrumConnection(config: config)
testResult = "Success — chain tip at block \(height)"
} catch {
testResult = "Failed: \(error.localizedDescription)"
testResult = "Failed: \(BitcoinService.friendlyElectrumError(error))"
}
isTesting = false
}

View File

@ -1,15 +1,14 @@
import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "Navigation")
struct MainTabView: View {
@State private var selectedTab = 0
@State private var walletID: UUID?
@State private var resumePSBT: SavedPSBT?
@State private var showResumeAlert = false
@State private var pendingResumePSBT: SavedPSBT?
@State private var resumeBumpFeeViewModel: BumpFeeViewModel?
@State private var hasCheckedResume = false
@Query(sort: \SavedPSBT.updatedAt, order: .reverse) private var allSavedPSBTs: [SavedPSBT]
private static let tabNames = ["Transactions", "Send", "Receive", "UTXOs", "Settings"]
var body: some View {
TabView(selection: $selectedTab) {
@ -18,7 +17,7 @@ struct MainTabView: View {
}
Tab("Send", systemImage: "arrow.up.right", value: 1) {
SendFlowView(selectedTab: $selectedTab, resumePSBT: $resumePSBT)
SendFlowView(selectedTab: $selectedTab)
}
Tab("Receive", systemImage: "arrow.down.left", value: 2) {
@ -37,50 +36,15 @@ struct MainTabView: View {
}
}
.tint(Color.hbBitcoinOrange)
.onChange(of: selectedTab) { _, newTab in
let name = newTab < Self.tabNames.count ? Self.tabNames[newTab] : "Unknown"
logger.info("Tab changed to \(name, privacy: .public)")
}
.onAppear {
walletID = BitcoinService.shared.currentProfile?.id
}
.onChange(of: BitcoinService.shared.currentProfile?.id) {
walletID = BitcoinService.shared.currentProfile?.id
checkForInProgressPSBT()
}
.onChange(of: allSavedPSBTs.count) {
checkForInProgressPSBT()
}
.alert("Resume Signing?", isPresented: $showResumeAlert) {
Button("Yes") {
guard let saved = pendingResumePSBT else { return }
pendingResumePSBT = nil
if saved.originalTxid != nil {
resumeBumpFeeViewModel = BumpFeeViewModel(savedPSBT: saved)
} else {
selectedTab = 1
// Delay so the tab switch completes before SendFlowView loads the PSBT
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
resumePSBT = saved
}
}
}
Button("No", role: .cancel) {
pendingResumePSBT = nil
}
} message: {
Text("You have a PSBT that was being signed. Would you like to resume?")
}
.sheet(item: $resumeBumpFeeViewModel) { vm in
BumpFeeView(viewModel: vm)
}
}
private func checkForInProgressPSBT() {
guard !hasCheckedResume else { return }
guard let walletID = BitcoinService.shared.currentProfile?.id else { return }
guard !allSavedPSBTs.isEmpty else { return }
hasCheckedResume = true
if let saved = allSavedPSBTs.first(where: { $0.walletID == walletID }) {
pendingResumePSBT = saved
showResumeAlert = true
}
}
}

View File

@ -73,7 +73,9 @@ struct AddressListView: View {
.background(Color.hbBackground)
.navigationTitle("Addresses")
.onAppear {
viewModel.loadAddresses()
if let profileId = BitcoinService.shared.currentProfile?.id {
viewModel.loadAddresses(for: profileId)
}
}
}

View File

@ -119,7 +119,9 @@ struct ReceiveView: View {
.id(walletID)
.onAppear {
walletID = BitcoinService.shared.currentProfile?.id
viewModel.loadAddress()
if let walletID {
viewModel.loadAddress(for: walletID)
}
loadLabel()
}
.onChange(of: viewModel.currentAddress) {
@ -128,7 +130,9 @@ struct ReceiveView: View {
}
.onChange(of: BitcoinService.shared.currentProfile?.id) {
walletID = BitcoinService.shared.currentProfile?.id
viewModel.loadAddress()
if let walletID {
viewModel.loadAddress(for: walletID)
}
copied = false
isEditingLabel = false
loadLabel()

View File

@ -1,3 +1,4 @@
import OSLog
import SwiftData
import SwiftUI
@ -6,19 +7,40 @@ struct BroadcastResultView: View {
@Binding var selectedTab: Int
@Environment(\.modelContext) private var modelContext
@State private var showSuccess = false
@State private var checkmarkScale: CGFloat = 0
@State private var ringScale: CGFloat = 0
@State private var ringOpacity: Double = 1
@State private var textOpacity: Double = 0
@State private var txidOpacity: Double = 0
@State private var countdownSeconds = 5
@State private var countdownTimer: Timer?
var body: some View {
VStack(spacing: 24) {
Spacer()
if !viewModel.broadcastTxid.isEmpty {
// Success state
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(Color.hbSuccess)
if showSuccess {
// Animated success state
ZStack {
// Expanding ring
Circle()
.strokeBorder(Color.hbSuccess, lineWidth: 3)
.frame(width: 120, height: 120)
.scaleEffect(ringScale)
.opacity(ringOpacity)
Text("Transaction Broadcast")
// Checkmark
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 72))
.foregroundStyle(Color.hbSuccess)
.scaleEffect(checkmarkScale)
}
Text("Transaction Broadcast!")
.font(.hbDisplay(24))
.foregroundStyle(Color.hbTextPrimary)
.opacity(textOpacity)
VStack(spacing: 8) {
Text("Transaction ID")
@ -39,7 +61,8 @@ struct BroadcastResultView: View {
.foregroundStyle(Color.hbSteelBlue)
}
}
} else {
.opacity(txidOpacity)
} else if viewModel.broadcastTxid.isEmpty {
// Ready to broadcast
SignatureProgressView(
collected: viewModel.signaturesCollected,
@ -62,7 +85,16 @@ struct BroadcastResultView: View {
Spacer()
if viewModel.broadcastTxid.isEmpty {
if showSuccess {
Button(action: {
navigateToTransactions()
}) {
Text("View Transactions (\(countdownSeconds))")
.hbPrimaryButton()
}
.padding(.horizontal, 24)
.opacity(txidOpacity)
} else if viewModel.broadcastTxid.isEmpty {
Button(action: {
Task { await viewModel.broadcast() }
}) {
@ -83,18 +115,9 @@ struct BroadcastResultView: View {
.font(.hbBody(15))
.foregroundStyle(Color.hbBitcoinOrange)
}
} else {
Button(action: {
viewModel.reset()
selectedTab = 0
}) {
Text("Done")
.hbPrimaryButton()
}
.padding(.horizontal, 24)
}
if viewModel.broadcastTxid.isEmpty {
if viewModel.broadcastTxid.isEmpty, !showSuccess {
Button(action: {
viewModel.currentStep = .recipients
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
@ -116,13 +139,61 @@ struct BroadcastResultView: View {
if !viewModel.broadcastTxid.isEmpty {
saveRecipientLabels()
viewModel.deleteSavedPSBT(context: modelContext)
playCelebration()
}
}
.onDisappear {
countdownTimer?.invalidate()
}
.sheet(isPresented: $viewModel.showExportQR) {
ExportTransactionSheet(txBytes: viewModel.finalizedTxBytes)
}
}
private func playCelebration() {
// Staggered animation sequence
withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) {
showSuccess = true
checkmarkScale = 1.0
}
withAnimation(.easeOut(duration: 0.6)) {
ringScale = 2.0
}
withAnimation(.easeOut(duration: 0.6).delay(0.3)) {
ringOpacity = 0
}
withAnimation(.easeIn(duration: 0.3).delay(0.25)) {
textOpacity = 1
}
withAnimation(.easeIn(duration: 0.3).delay(0.5)) {
txidOpacity = 1
}
// Auto-navigate countdown
countdownSeconds = 5
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
Task { @MainActor in
countdownSeconds -= 1
if countdownSeconds <= 0 {
timer.invalidate()
navigateToTransactions()
}
}
}
}
private func navigateToTransactions() {
countdownTimer?.invalidate()
viewModel.reset()
selectedTab = 0
// Sync after navigation so the new transaction appears in the list
Task {
try? await BitcoinService.shared.sync()
}
}
private func saveRecipientLabels() {
guard let walletID = BitcoinService.shared.currentProfile?.id else { return }
var firstLabel: String?
@ -162,14 +233,19 @@ struct BroadcastResultView: View {
if let changeAddress = viewModel.changeAddress, !changeAddress.isEmpty,
let changeVout = BitcoinService.shared.psbtChangeVout(viewModel.psbtBytes, changeAddress: changeAddress)
{
LabelService.propagateChangeLabel(
txid: txid,
txLabel: txLabel,
changeAddress: changeAddress,
changeVout: changeVout,
context: modelContext,
walletID: walletID
)
do {
try LabelService.propagateChangeLabel(
txid: txid,
txLabel: txLabel,
changeAddress: changeAddress,
changeVout: changeVout,
context: modelContext,
walletID: walletID
)
} catch {
Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "LabelService")
.error("Failed to propagate change label: \(error.localizedDescription)")
}
}
}
try? modelContext.save()

View File

@ -24,7 +24,7 @@ struct PSBTScanView: View {
)
// QR Scanner
URScannerSheet { result in
URScannerSheet(preferMacroCamera: true) { result in
if case let .psbt(data) = result {
Task { await viewModel.handleSignedPSBT(data, modelContext: modelContext) }
}

View File

@ -4,6 +4,9 @@ import SwiftUI
struct SendRecipientsView: View {
@Bindable var viewModel: SendViewModel
var resumeCandidate: SavedPSBT?
var onResumeYes: (SavedPSBT) -> Void = { _ in }
var onResumeNo: () -> Void = {}
@AppStorage(Constants.denominationKey) private var denomination: String = "sats"
@AppStorage(Constants.fiatEnabledKey) private var fiatEnabled = false
@ -13,6 +16,16 @@ struct SendRecipientsView: View {
SendStepIndicator(currentStep: viewModel.currentStep)
.padding(.horizontal, 24)
// Resume signing card
if let saved = resumeCandidate {
ResumeSigningCard(
savedPSBT: saved,
onYes: { onResumeYes(saved) },
onNo: onResumeNo
)
.padding(.horizontal, 24)
}
// Spendable balance
if viewModel.manualUTXOSelection {
Text("Selected: \(viewModel.selectedUTXOTotal.formattedSats) (\(viewModel.selectedUTXOIds.count) UTXOs)")
@ -861,6 +874,49 @@ class QRScannerUIView: UIView, AVCaptureMetadataOutputObjectsDelegate {
}
}
// MARK: - Resume Signing Card
private struct ResumeSigningCard: View {
let savedPSBT: SavedPSBT
let onYes: () -> Void
let onNo: () -> Void
var body: some View {
VStack(spacing: 12) {
Text("Resume signing last PSBT?")
.font(.hbBody(15))
.foregroundStyle(Color.hbTextPrimary)
Text(savedPSBT.name)
.font(.hbBody(13))
.foregroundStyle(Color.hbTextSecondary)
HStack(spacing: 12) {
Button(action: onNo) {
Text("No")
.font(.hbBody(14))
.foregroundStyle(Color.hbTextSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button(action: onYes) {
Text("Yes")
.font(.hbBody(14))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.hbBitcoinOrange)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
.hbCard()
}
}
// MARK: - Step Indicator
struct SendStepIndicator: View {

View File

@ -4,11 +4,14 @@ import UniformTypeIdentifiers
struct SendFlowView: View {
@Binding var selectedTab: Int
@Binding var resumePSBT: SavedPSBT?
@State private var viewModel = SendViewModel()
@Query private var frozenUTXOs: [FrozenUTXO]
@Query(sort: \SavedPSBT.updatedAt, order: .reverse) private var allSavedPSBTs: [SavedPSBT]
@Environment(\.modelContext) private var modelContext
@State private var bumpFeeViewModel: BumpFeeViewModel?
@State private var resumeCandidate: SavedPSBT?
@State private var hasCheckedResume = false
@State private var resumeDismissed = false
var body: some View {
NavigationStack {
ZStack {
@ -47,7 +50,12 @@ struct SendFlowView: View {
Group {
switch viewModel.currentStep {
case .recipients:
SendRecipientsView(viewModel: viewModel)
SendRecipientsView(
viewModel: viewModel,
resumeCandidate: resumeCandidate,
onResumeYes: { saved in resumePSBT(saved) },
onResumeNo: { dismissResume() }
)
case .review:
SendReviewView(viewModel: viewModel)
case .psbtDisplay:
@ -73,10 +81,7 @@ struct SendFlowView: View {
.onAppear {
loadFrozenOutpoints()
viewModel.loadBalance()
if let saved = resumePSBT {
viewModel.loadSavedPSBT(saved)
resumePSBT = nil
}
checkForResumablePSBT()
}
.onChange(of: frozenUTXOs.count) {
loadFrozenOutpoints()
@ -86,12 +91,10 @@ struct SendFlowView: View {
viewModel.reset()
loadFrozenOutpoints()
viewModel.loadBalance()
}
.onChange(of: resumePSBT) { _, saved in
if let saved {
viewModel.loadSavedPSBT(saved)
resumePSBT = nil
}
resumeCandidate = nil
hasCheckedResume = false
resumeDismissed = false
checkForResumablePSBT()
}
.sheet(isPresented: $viewModel.showLoadPSBT) {
SavedPSBTListView(viewModel: viewModel) { savedPSBT in
@ -133,6 +136,30 @@ struct SendFlowView: View {
guard let walletID = BitcoinService.shared.currentProfile?.id else { return }
viewModel.frozenOutpoints = Set(frozenUTXOs.filter { $0.walletID == walletID }.map(\.outpoint))
}
private func checkForResumablePSBT() {
guard !hasCheckedResume, !resumeDismissed else { return }
guard let walletID = BitcoinService.shared.currentProfile?.id else { return }
hasCheckedResume = true
if let saved = allSavedPSBTs.first(where: { $0.walletID == walletID }) {
resumeCandidate = saved
}
}
func resumePSBT(_ saved: SavedPSBT) {
resumeCandidate = nil
if saved.originalTxid != nil {
bumpFeeViewModel = BumpFeeViewModel(savedPSBT: saved)
} else {
viewModel.loadSavedPSBT(saved)
}
}
func dismissResume() {
resumeCandidate = nil
resumeDismissed = true
}
}
// MARK: - Import PSBT via QR Sheet
@ -144,7 +171,7 @@ struct ImportPSBTQRSheet: View {
var body: some View {
NavigationStack {
URScannerSheet { result in
URScannerSheet(preferMacroCamera: true) { result in
switch result {
case let .psbt(data):
viewModel.importPSBT(data, source: "QR", context: modelContext)

View File

@ -1,13 +1,17 @@
import LocalAuthentication
import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "Settings")
struct SettingsView: View {
@Environment(\.modelContext) private var modelContext
@Query private var wallets: [WalletProfile]
@State private var viewModel = WalletManagerViewModel()
@State private var walletToDelete: WalletProfile?
@State private var showAddWallet = false
@State private var showLogExport = false
var body: some View {
NavigationStack {
@ -81,9 +85,7 @@ struct SettingsView: View {
}
// Security
Section("Security") {
AppLockSettingsRow()
}
AppLockSettingsSection()
// Appearance
Section("Appearance") {
@ -108,6 +110,13 @@ struct SettingsView: View {
Spacer()
Text("\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
.foregroundStyle(Color.hbTextSecondary)
Button(action: { showLogExport = true }) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 14))
.foregroundStyle(Color.hbTextSecondary)
}
.buttonStyle(.plain)
}
.listRowBackground(Color.hbSurface)
}
@ -134,6 +143,9 @@ struct SettingsView: View {
} message: {
Text("This will permanently delete \"\(walletToDelete?.name ?? "")\" and all its data.")
}
.sheet(isPresented: $showLogExport) {
LogExportSheet()
}
}
}
}
@ -153,6 +165,7 @@ private struct AppearanceSettingsRow: View {
.foregroundStyle(Color.hbTextPrimary)
.onChange(of: themeRaw) { _, new in
if let t = AppTheme(rawValue: new) {
logger.info("Theme changed to \(t.displayName, privacy: .public)")
ThemeManager.shared.apply(t)
}
}
@ -195,6 +208,9 @@ private struct FeeSettingsRow: View {
.tint(Color.hbBitcoinOrange)
.foregroundStyle(Color.hbTextPrimary)
.listRowBackground(Color.hbSurface)
.onChange(of: feeSourceRaw) { _, new in
logger.info("Fee source changed to \(new, privacy: .public)")
}
}
}
@ -208,7 +224,13 @@ private struct FiatSettingsRow: View {
var body: some View {
VStack(spacing: 0) {
Toggle(isOn: $fiatEnabled) {
Toggle(isOn: Binding(
get: { fiatEnabled },
set: { new in
logger.info("Fiat display \(new ? "enabled" : "disabled", privacy: .public)")
fiatEnabled = new
}
)) {
VStack(alignment: .leading, spacing: 2) {
Text("Show Fiat Price")
.foregroundStyle(Color.hbTextPrimary)
@ -229,6 +251,7 @@ private struct FiatSettingsRow: View {
.foregroundStyle(Color.hbTextPrimary)
.padding(.top, 12)
.onChange(of: fiatSourceRaw) {
logger.info("Fiat source changed to \(fiatSourceRaw, privacy: .public)")
fiatService.resetCache()
Task { await fiatService.fetchRates() }
}
@ -249,10 +272,22 @@ private struct FiatSettingsRow: View {
// MARK: - App Lock Settings
private struct AppLockSettingsRow: View {
private struct AppLockSettingsSection: View {
@AppStorage(Constants.appLockEnabledKey) private var appLockEnabled = false
@AppStorage(Constants.appLockTimeoutKey) private var lockTimeout = 60
@State private var showBiometricError = false
@State private var biometricErrorMessage = ""
@State private var showSetPIN = false
@State private var showRemovePIN = false
@State private var lockVM = AppLockViewModel()
private static let timeoutOptions: [(String, Int)] = [
("1 minute", 60),
("5 minutes", 300),
("15 minutes", 900),
("30 minutes", 1800),
("60 minutes", 3600),
]
private var biometricLabel: String {
let context = LAContext()
@ -266,13 +301,15 @@ private struct AppLockSettingsRow: View {
}
var body: some View {
VStack(spacing: 0) {
Section("Security") {
Toggle(isOn: Binding(
get: { appLockEnabled },
set: { newValue in
if newValue {
authenticateToEnable()
} else {
logger.info("App lock disabled")
lockVM.removePIN()
appLockEnabled = false
}
}
@ -286,12 +323,56 @@ private struct AppLockSettingsRow: View {
}
}
.tint(Color.hbBitcoinOrange)
}
.listRowBackground(Color.hbSurface)
.alert("Authentication Unavailable", isPresented: $showBiometricError) {
Button("OK") {}
} message: {
Text(biometricErrorMessage)
.listRowBackground(Color.hbSurface)
.alert("Authentication Unavailable", isPresented: $showBiometricError) {
Button("OK") {}
} message: {
Text(biometricErrorMessage)
}
.sheet(isPresented: $showSetPIN) {
SetPINSheet(lockVM: lockVM)
}
.sheet(isPresented: $showRemovePIN) {
RemovePINSheet(lockVM: lockVM)
}
if appLockEnabled {
Picker("Lock After", selection: $lockTimeout) {
ForEach(Self.timeoutOptions, id: \.1) { option in
Text(option.0).tag(option.1)
}
}
.tint(Color.hbBitcoinOrange)
.foregroundStyle(Color.hbTextPrimary)
.listRowBackground(Color.hbSurface)
.onChange(of: lockTimeout) { _, new in
logger.info("Lock timeout changed to \(new)s")
}
if lockVM.hasPIN {
Button(role: .destructive) {
showRemovePIN = true
} label: {
Text("Remove PIN")
.foregroundStyle(Color.hbError)
}
.listRowBackground(Color.hbSurface)
} else {
Button {
showSetPIN = true
} label: {
HStack {
Text("Add Additional PIN")
.foregroundStyle(Color.hbBitcoinOrange)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.hbTextSecondary)
}
}
.listRowBackground(Color.hbSurface)
}
}
}
}
@ -306,9 +387,262 @@ private struct AppLockSettingsRow: View {
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Verify your identity to enable app lock") { success, _ in
DispatchQueue.main.async {
if success {
logger.info("App lock enabled")
appLockEnabled = true
}
}
}
}
}
// MARK: - Set PIN Sheet
private struct SetPINSheet: View {
@Bindable var lockVM: AppLockViewModel
@Environment(\.dismiss) private var dismiss
@State private var step: SetPINStep = .create
@State private var firstPIN = ""
@State private var confirmPIN = ""
@State private var error = ""
private enum SetPINStep {
case create
case confirm
}
var body: some View {
NavigationStack {
VStack {
Spacer()
switch step {
case .create:
PINPadView(
title: "Create PIN",
subtitle: error,
dotCount: 8,
minDigits: 4,
mode: .create,
pin: $firstPIN,
isDisabled: false,
onComplete: { pin in
firstPIN = pin
error = ""
confirmPIN = ""
step = .confirm
},
hint: "Choose a PIN between 4 and 8 digits"
)
case .confirm:
PINPadView(
title: "Confirm PIN",
subtitle: error,
dotCount: firstPIN.count,
minDigits: firstPIN.count,
mode: .verify,
pin: $confirmPIN,
isDisabled: false,
onComplete: { pin in
if pin == firstPIN {
lockVM.setPIN(pin)
dismiss()
} else {
error = "PINs don't match — try again"
firstPIN = ""
confirmPIN = ""
step = .create
}
}
)
}
Spacer()
}
.padding(.horizontal, 16)
.background(Color.hbBackground)
.navigationTitle("Set PIN")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(Color.hbBitcoinOrange)
}
}
}
}
}
// MARK: - Remove PIN Sheet
private struct RemovePINSheet: View {
@Bindable var lockVM: AppLockViewModel
@Environment(\.dismiss) private var dismiss
@State private var pin = ""
@State private var error = ""
@State private var lockoutTimer: Timer?
var body: some View {
NavigationStack {
VStack {
Spacer()
PINPadView(
title: "Enter Current PIN",
subtitle: lockVM.isLockedOut ? lockVM.lockoutRemainingText : error,
dotCount: lockVM.storedPINLength,
minDigits: lockVM.storedPINLength,
mode: .verify,
pin: $pin,
isDisabled: lockVM.isLockedOut,
onComplete: { entered in
if lockVM.verifyPIN(entered) {
// verifyPIN unlocked re-lock since we're in settings
lockVM.isLocked = false
lockVM.removePIN()
dismiss()
} else {
error = lockVM.pinError
pin = ""
startLockoutTimerIfNeeded()
}
}
)
Spacer()
}
.padding(.horizontal, 16)
.background(Color.hbBackground)
.navigationTitle("Remove PIN")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.foregroundStyle(Color.hbBitcoinOrange)
}
}
.onAppear { startLockoutTimerIfNeeded() }
.onDisappear { lockoutTimer?.invalidate() }
}
}
private func startLockoutTimerIfNeeded() {
guard lockVM.isLockedOut else { return }
lockoutTimer?.invalidate()
lockoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak lockVM] timer in
MainActor.assumeIsolated {
guard let lockVM else {
timer.invalidate()
return
}
if !lockVM.isLockedOut {
timer.invalidate()
error = ""
} else {
error = lockVM.lockoutRemainingText
}
}
}
}
}
// MARK: - Log Export Sheet
private struct LogExportSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var logText: String = ""
@State private var isLoading = true
@State private var copied = false
@State private var hours: Double = 1
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Time range picker
HStack(spacing: 12) {
Text("Last")
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Picker("", selection: $hours) {
Text("1h").tag(1.0)
Text("4h").tag(4.0)
Text("12h").tag(12.0)
Text("24h").tag(24.0)
}
.pickerStyle(.segmented)
.onChange(of: hours) {
loadLogs()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if isLoading {
Spacer()
ProgressView()
.tint(Color.hbBitcoinOrange)
Spacer()
} else {
ScrollView {
Text(logText)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.textSelection(.enabled)
}
.background(Color.hbSurfaceElevated)
}
}
.background(Color.hbBackground)
.navigationTitle("Debug Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
.foregroundStyle(Color.hbBitcoinOrange)
}
ToolbarItem(placement: .primaryAction) {
HStack(spacing: 12) {
ShareLink(item: logText) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 14))
}
.foregroundStyle(Color.hbBitcoinOrange)
Button(action: copyLogs) {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(copied ? Color.hbSuccess : Color.hbBitcoinOrange)
}
}
}
}
.onAppear { loadLogs() }
}
}
private func loadLogs() {
isLoading = true
Task {
let text: String
do {
text = try LogExporter.collectLogs(hours: hours)
} catch {
text = "Failed to read logs: \(error.localizedDescription)"
}
await MainActor.run {
logText = text
isLoading = false
}
}
}
private func copyLogs() {
UIPasteboard.general.string = logText
copied = true
Task {
try? await Task.sleep(for: .seconds(2))
await MainActor.run { copied = false }
}
}
}

View File

@ -1,6 +1,9 @@
import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "UTXODetail")
struct UTXODetailView: View {
let utxo: UTXOItem
@Environment(\.modelContext) private var modelContext
@ -13,6 +16,10 @@ struct UTXODetailView: View {
@State private var isEditingLabel = false
@State private var editedLabel: String = ""
private var isPrivate: Bool {
BitcoinService.shared.currentProfile?.privacyMode ?? false
}
private var service: BitcoinService {
BitcoinService.shared
}
@ -41,7 +48,11 @@ struct UTXODetailView: View {
.font(.system(size: 44))
.foregroundStyle(isFrozen ? Color.hbSteelBlue : Color.hbBitcoinOrange)
if fiatEnabled, fiatPrimary, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(utxo.amount) {
if isPrivate {
Text(Constants.privacyText())
.font(.hbAmountMedium)
.foregroundStyle(Color.hbTextPrimary)
} else if fiatEnabled, fiatPrimary, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(utxo.amount) {
Text(fiatStr)
.font(.hbAmountMedium)
.foregroundStyle(Color.hbTextPrimary)
@ -93,29 +104,39 @@ struct UTXODetailView: View {
.foregroundStyle(Color.hbTextSecondary)
HStack(alignment: .top, spacing: 8) {
Text(address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.textSelection(.enabled)
Group {
if isPrivate {
Text(Constants.privacyText(length: 8))
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
} else {
Text(address)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
.textSelection(.enabled)
}
}
Spacer()
Button(action: {
UIPasteboard.general.string = address
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
if let network = service.currentNetwork,
let url = network.explorerAddressURL(address: address, customHost: service.currentProfile?.blockExplorerHost)
{
Link(destination: url) {
Image(systemName: "arrow.up.right.square")
if !isPrivate {
Button(action: {
UIPasteboard.general.string = address
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
if let network = service.currentNetwork,
let url = network.explorerAddressURL(address: address, customHost: service.currentProfile?.blockExplorerHost)
{
Link(destination: url) {
Image(systemName: "arrow.up.right.square")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
}
}
}
@ -133,7 +154,7 @@ struct UTXODetailView: View {
Divider().overlay(Color.hbBorder)
}
DetailRow(label: "Amount", value: utxo.amount.formattedSats)
DetailRow(label: "Amount", value: isPrivate ? Constants.privacyText() : utxo.amount.formattedSats)
DetailRow(label: "Output Index", value: "\(utxo.vout)")
@ -207,29 +228,39 @@ struct UTXODetailView: View {
.foregroundStyle(Color.hbTextSecondary)
HStack(alignment: .top, spacing: 8) {
Text(utxo.id)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.textSelection(.enabled)
Group {
if isPrivate {
Text(Constants.privacyText(length: 8))
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
} else {
Text(utxo.id)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.textSelection(.enabled)
}
}
Spacer()
Button(action: {
UIPasteboard.general.string = utxo.id
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
if let network = service.currentNetwork,
let url = network.explorerTxURL(txid: utxo.txid, customHost: service.currentProfile?.blockExplorerHost)
{
Link(destination: url) {
Image(systemName: "arrow.up.right.square")
if !isPrivate {
Button(action: {
UIPasteboard.general.string = utxo.id
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
if let network = service.currentNetwork,
let url = network.explorerTxURL(txid: utxo.txid, customHost: service.currentProfile?.blockExplorerHost)
{
Link(destination: url) {
Image(systemName: "arrow.up.right.square")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
}
}
}
}
@ -284,10 +315,6 @@ struct UTXODetailView: View {
} else {
modelContext.insert(WalletLabel(walletID: walletID, type: .utxo, ref: outpoint, label: trimmed))
}
try? modelContext.save()
utxoLabel = trimmed
isEditingLabel = false
// Propagate to receive address if unlabeled
if !trimmed.isEmpty, utxo.keychain == .external, let address = outputAddress {
let addrType = "addr"
@ -296,9 +323,15 @@ struct UTXODetailView: View {
})
if (try? modelContext.fetch(addrDescriptor))?.first == nil {
modelContext.insert(WalletLabel(walletID: walletID, type: .addr, ref: address, label: trimmed))
try? modelContext.save()
}
}
do {
try modelContext.save()
} catch {
logger.error("Failed to save UTXO label: \(error)")
}
utxoLabel = trimmed
isEditingLabel = false
}
private func addressLabel(for address: String) -> String? {
@ -316,11 +349,14 @@ struct UTXODetailView: View {
let outpoint = utxo.id
if let frozen = (try? modelContext.fetch(descriptor))?.first(where: { $0.outpoint == outpoint }) {
modelContext.delete(frozen)
try? modelContext.save()
}
} else {
modelContext.insert(FrozenUTXO(walletID: walletID, txid: utxo.txid, vout: utxo.vout))
try? modelContext.save()
}
do {
try modelContext.save()
} catch {
logger.error("Failed to save UTXO freeze toggle: \(error)")
}
}
}

View File

@ -10,6 +10,10 @@ struct UTXOListView: View {
@AppStorage(Constants.fiatEnabledKey) private var fiatEnabled = false
@AppStorage(Constants.fiatPrimaryKey) private var fiatPrimary = false
private var isPrivate: Bool {
BitcoinService.shared.currentProfile?.privacyMode ?? false
}
var body: some View {
VStack(spacing: 0) {
// Title
@ -32,7 +36,11 @@ struct UTXOListView: View {
NavigationLink(destination: UTXODetailView(utxo: utxo)) {
VStack(alignment: .leading, spacing: 6) {
HStack {
if fiatEnabled, fiatPrimary, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(utxo.amount) {
if isPrivate {
Text(Constants.privacyText())
.font(.hbMono(14))
.foregroundStyle(isFrozen(utxo) ? Color.hbTextSecondary : Color.hbTextPrimary)
} else if fiatEnabled, fiatPrimary, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(utxo.amount) {
Text(fiatStr)
.font(.hbMono(14))
.foregroundStyle(isFrozen(utxo) ? Color.hbTextSecondary : Color.hbTextPrimary)
@ -64,7 +72,11 @@ struct UTXOListView: View {
}
HStack {
if let address = viewModel.address(for: utxo) {
if isPrivate {
Text(Constants.privacyText(length: 8))
.font(.hbMono(11))
.foregroundStyle(Color.hbTextSecondary)
} else if let address = viewModel.address(for: utxo) {
Text(address.truncatedMiddle(leading: 10, trailing: 8))
.font(.hbMono(11))
.foregroundStyle(Color.hbTextSecondary)

View File

@ -1,9 +1,13 @@
import OSLog
import SwiftData
import SwiftUI
import URKit
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "WalletInfo")
struct WalletInfoView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Bindable var wallet: WalletProfile
@State private var gapLimitText: String = ""
@State private var isResyncing = false
@ -17,7 +21,9 @@ struct WalletInfoView: View {
@State private var isTestingConnection = false
@State private var connectionTestResult: String?
@State private var blockExplorerText: String = ""
@State private var initialElectrumConfig: ElectrumConfig?
@State private var showDescriptorQR = false
@State private var showDeleteConfirmation = false
@State private var showDescriptorPDF = false
private var combinedDescriptor: String {
@ -348,10 +354,52 @@ struct WalletInfoView: View {
}
}
.hbCard()
// Privacy Mode
VStack(spacing: 12) {
Toggle(isOn: Binding(
get: { wallet.privacyMode },
set: { new in
logger.info("Privacy mode \(new ? "enabled" : "disabled", privacy: .public)")
wallet.privacyMode = new
}
)) {
VStack(alignment: .leading, spacing: 2) {
Text("Privacy Mode")
.foregroundStyle(Color.hbTextPrimary)
Text("Hide balances, addresses, and transaction details")
.font(.hbBody(12))
.foregroundStyle(Color.hbTextSecondary)
}
}
.tint(Color.hbBitcoinOrange)
}
.hbCard()
// Delete Wallet
Button(action: { showDeleteConfirmation = true }) {
HStack(spacing: 8) {
Image(systemName: "trash")
Text("Delete Wallet")
.font(.hbBody(15))
}
.foregroundStyle(Color.hbError)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.hbError.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.padding(.top, 16)
}
.padding(16)
}
.scrollDismissesKeyboard(.interactively)
.alert("Delete Wallet", isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) { deleteWallet() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete \"\(wallet.name)\"? This cannot be undone. You can re-import using your output descriptor.")
}
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
@ -362,6 +410,18 @@ struct WalletInfoView: View {
electrumHostText = wallet.electrumHost
electrumPortText = wallet.electrumPort > 0 ? "\(wallet.electrumPort)" : ""
blockExplorerText = wallet.blockExplorerHost
initialElectrumConfig = wallet.electrumConfig
}
.onDisappear {
if let initial = initialElectrumConfig, wallet.electrumConfig != initial {
logger.info("Electrum settings changed — reloading wallet")
let service = BitcoinService.shared
Task {
service.unloadWallet()
try? await service.loadWallet(profile: wallet)
try? await service.sync()
}
}
}
.sheet(isPresented: $showEditCosigners) {
EditCosignersView(wallet: wallet)
@ -377,12 +437,15 @@ struct WalletInfoView: View {
private func testElectrumConnection() {
isTestingConnection = true
connectionTestResult = nil
let config = wallet.electrumConfig
logger.info("Testing Electrum connection to \(config.url, privacy: .public)")
Task {
let config = wallet.electrumConfig
do {
try await BitcoinService.shared.testElectrumConnection(config: config)
logger.info("Electrum connection test succeeded")
connectionTestResult = "Success — server responded to ping"
} catch {
logger.error("Electrum connection test failed: \(error)")
connectionTestResult = "Failed: \(error.localizedDescription)"
}
isTestingConnection = false
@ -390,6 +453,7 @@ struct WalletInfoView: View {
}
private func resetElectrumDefaults() {
logger.info("Electrum config reset to defaults")
wallet.electrumHost = ""
wallet.electrumPort = 0
wallet.electrumSSL = 0
@ -401,12 +465,14 @@ struct WalletInfoView: View {
private func saveName() {
let trimmed = editedName.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
logger.info("Wallet name changed to \(trimmed, privacy: .private)")
wallet.name = trimmed
}
isEditingName = false
}
private func forceResync() {
logger.info("Force full resync initiated")
isResyncing = true
resyncError = nil
resyncSuccess = false
@ -414,13 +480,21 @@ struct WalletInfoView: View {
Task {
do {
try await BitcoinService.shared.fullResync()
logger.info("Force resync completed")
resyncSuccess = true
} catch {
logger.error("Force resync failed: \(error)")
resyncError = error.localizedDescription
}
isResyncing = false
}
}
private func deleteWallet() {
let walletManager = WalletManagerViewModel()
walletManager.deleteWallet(wallet, modelContext: modelContext)
dismiss()
}
}
// MARK: - Edit Cosigners Sheet
@ -722,6 +796,8 @@ private struct EditCosignersView: View {
wallet.externalDescriptor = extDesc
wallet.internalDescriptor = intDesc
logger.info("Cosigner changes saved, rebuilding descriptors")
// Delete old BDK wallet database so it reloads fresh
let dbPath = Constants.walletDatabasePath(for: wallet.id)
try? FileManager.default.removeItem(at: dbPath)

View File

@ -64,7 +64,7 @@ private struct BumpFeeInputView: View {
}
DetailRow(label: "Minimum to Bump") {
Text(BumpFeeViewModel.formatRate(viewModel.minimumBumpRate) + " sat/vB")
Text(formatFeeRate(viewModel.minimumBumpRate) + " sat/vB")
.font(.hbMono())
.foregroundStyle(Color.hbBitcoinOrange)
}
@ -108,7 +108,7 @@ private struct BumpFeeRateCard: View {
private var currentRateText: String {
viewModel.feeRateValue > 0
? BumpFeeViewModel.formatRate(viewModel.feeRateValue) + " sat/vB"
? formatFeeRate(viewModel.feeRateValue) + " sat/vB"
: "--"
}
@ -180,7 +180,7 @@ private struct BumpFeeRateCard: View {
Spacer()
if let rate = preset.rate(from: viewModel.recommendedFees) {
Text(BumpFeeViewModel.formatRate(rate) + " sat/vB")
Text(formatFeeRate(rate) + " sat/vB")
.font(.hbMono(13))
.foregroundStyle(Color.hbTextPrimary)
} else {
@ -234,7 +234,7 @@ private struct BumpFeeRateCard: View {
if filtered != newValue { viewModel.newFeeRate = filtered }
// Only switch to .custom when the value wasn't set by applyPreset
if let rate = viewModel.selectedFeePreset.rate(from: viewModel.recommendedFees),
viewModel.newFeeRate == BumpFeeViewModel.formatRate(rate)
viewModel.newFeeRate == formatFeeRate(rate)
{
// Value matches the selected preset applyPreset wrote this, leave preset as-is
} else {
@ -473,7 +473,7 @@ private struct BumpFeePSBTScanView: View {
required: viewModel.requiredSignatures
)
URScannerSheet { result in
URScannerSheet(preferMacroCamera: true) { result in
if case let .psbt(data) = result {
Task { await viewModel.handleSignedPSBT(data, modelContext: modelContext) }
}

View File

@ -1,6 +1,9 @@
import OSLog
import SwiftData
import SwiftUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "TransactionDetailView")
struct TransactionDetailView: View {
let transaction: TransactionItem
var network: BitcoinNetwork?
@ -14,6 +17,10 @@ struct TransactionDetailView: View {
@State private var editedLabel: String = ""
@State private var showBumpFee = false
private var isPrivate: Bool {
BitcoinService.shared.currentProfile?.privacyMode ?? false
}
var body: some View {
ScrollView {
VStack(spacing: 20) {
@ -23,7 +30,11 @@ struct TransactionDetailView: View {
.font(.system(size: 44))
.foregroundStyle(transaction.isIncoming ? Color.hbSuccess : Color.hbBitcoinOrange)
if fiatEnabled, fiatPrimary, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(transaction.amount) {
if isPrivate {
Text(Constants.privacyText())
.font(.hbAmountMedium)
.foregroundStyle(Color.hbTextPrimary)
} else if fiatEnabled, fiatPrimary, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(transaction.amount) {
Text(fiatStr)
.font(.hbAmountMedium)
.foregroundStyle(Color.hbTextPrimary)
@ -101,27 +112,37 @@ struct TransactionDetailView: View {
.foregroundStyle(Color.hbTextSecondary)
HStack(alignment: .top, spacing: 8) {
Text(transaction.id)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.textSelection(.enabled)
Group {
if isPrivate {
Text(Constants.privacyText(length: 8))
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
} else {
Text(transaction.id)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.textSelection(.enabled)
}
}
Spacer()
Button(action: {
UIPasteboard.general.string = transaction.id
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
if let network, let url = network.explorerTxURL(txid: transaction.id, customHost: BitcoinService.shared.currentProfile?.blockExplorerHost) {
Link(destination: url) {
Image(systemName: "arrow.up.right.square")
if !isPrivate {
Button(action: {
UIPasteboard.general.string = transaction.id
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
if let network, let url = network.explorerTxURL(txid: transaction.id, customHost: BitcoinService.shared.currentProfile?.blockExplorerHost) {
Link(destination: url) {
Image(systemName: "arrow.up.right.square")
.font(.system(size: 14))
.foregroundStyle(Color.hbSteelBlue)
}
}
}
}
}
@ -179,14 +200,14 @@ struct TransactionDetailView: View {
if let fee = transaction.fee {
DetailRow(label: "Fee") {
Text(fee.formattedSats)
Text(isPrivate ? Constants.privacyText() : fee.formattedSats)
.font(.hbMono())
.foregroundStyle(Color.hbTextPrimary)
}
if let rate = transaction.currentFeeRate {
DetailRow(label: "Fee Rate") {
Text(String(format: rate == rate.rounded() ? "%.0f sat/vB" : "%.2f sat/vB", rate))
Text(isPrivate ? Constants.privacyText() : String(format: rate == rate.rounded() ? "%.0f sat/vB" : "%.2f sat/vB", rate))
.font(.hbMono())
.foregroundStyle(Color.hbTextPrimary)
}
@ -197,7 +218,7 @@ struct TransactionDetailView: View {
// Flow diagram
if !transaction.inputs.isEmpty || !transaction.outputs.isEmpty {
TransactionDetailFlowDiagram(transaction: transaction)
TransactionDetailFlowDiagram(transaction: transaction, isPrivate: isPrivate)
.hbCard()
}
@ -211,7 +232,7 @@ struct TransactionDetailView: View {
ForEach(transaction.inputs) { input in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(input.address)
Text(isPrivate ? Constants.privacyText(length: 8) : input.address)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(1)
@ -219,7 +240,7 @@ struct TransactionDetailView: View {
}
Spacer()
if input.amount > 0 {
Text(input.amount.formattedSats)
Text(isPrivate ? Constants.privacyText() : input.amount.formattedSats)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
}
@ -245,15 +266,22 @@ struct TransactionDetailView: View {
VStack(alignment: .leading, spacing: 4) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(output.address)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
if isPrivate {
Text(Constants.privacyText(length: 8))
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(1)
} else {
Text(output.address)
.font(.hbMono(11))
.foregroundStyle(Color.hbTextPrimary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
}
Spacer()
Text(output.amount.formattedSats)
Text(isPrivate ? Constants.privacyText() : output.amount.formattedSats)
.font(.hbMono(12))
.foregroundStyle(Color.hbTextPrimary)
}
@ -349,14 +377,18 @@ struct TransactionDetailView: View {
// Propagate to related UTXOs and receive address if this is an incoming tx
if !trimmed.isEmpty {
LabelService.propagateFromTxLabel(
txid: transaction.id,
newLabel: trimmed,
transaction: transaction,
utxos: BitcoinService.shared.utxos,
context: modelContext,
walletID: walletID
)
do {
try LabelService.propagateFromTxLabel(
txid: transaction.id,
newLabel: trimmed,
transaction: transaction,
utxos: BitcoinService.shared.utxos,
context: modelContext,
walletID: walletID
)
} catch {
logger.error("Failed to propagate tx label: \(error.localizedDescription)")
}
}
}
}
@ -365,6 +397,7 @@ struct TransactionDetailView: View {
private struct TransactionDetailFlowDiagram: View {
let transaction: TransactionItem
var isPrivate: Bool = false
private struct FlowEntry: Identifiable {
let id = UUID()
@ -379,7 +412,7 @@ private struct TransactionDetailFlowDiagram: View {
}
private var inputEntries: [FlowEntry] {
transaction.inputs.map { FlowEntry(label: shortAddress($0.address), isMine: $0.isMine, isPlaceholder: false) }
transaction.inputs.map { FlowEntry(label: isPrivate ? Constants.privacyText(length: 4) : shortAddress($0.address), isMine: $0.isMine, isPlaceholder: false) }
}
private var walletAddressMaps: (change: [String: UInt32], receive: [String: UInt32]) {
@ -400,7 +433,7 @@ private struct TransactionDetailFlowDiagram: View {
if output.isMine, let index = maps.receive[output.address] {
return FlowEntry(label: "Receive #\(index)", isMine: true, isPlaceholder: false)
}
return FlowEntry(label: shortAddress(output.address), isMine: output.isMine, isPlaceholder: false)
return FlowEntry(label: isPrivate ? Constants.privacyText(length: 4) : shortAddress(output.address), isMine: output.isMine, isPlaceholder: false)
}
if let fee = transaction.fee, fee > 0 {
let feeMine = !transaction.isIncoming

View File

@ -1,7 +1,10 @@
import OSLog
import SwiftData
import SwiftUI
import UniformTypeIdentifiers
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "hellbender", category: "TransactionListView")
struct TransactionListView: View {
@Query private var wallets: [WalletProfile]
@Query private var walletLabels: [WalletLabel]
@ -30,6 +33,10 @@ struct TransactionListView: View {
FiatPriceService.shared
}
private var isPrivate: Bool {
wallets.first(where: { $0.isActive })?.privacyMode ?? false
}
private func txLabel(for txid: String) -> String? {
guard let walletID = BitcoinService.shared.currentProfile?.id else { return nil }
return walletLabels.first(where: { $0.walletID == walletID && $0.type == "tx" && $0.ref == txid })?.label
@ -79,7 +86,7 @@ struct TransactionListView: View {
private func importLabelsFromFile(result: Result<[URL], Error>) {
switch result {
case .success(let urls):
case let .success(urls):
guard let url = urls.first else { return }
guard url.startAccessingSecurityScopedResource() else {
importResult = "Unable to access the selected file."
@ -96,17 +103,22 @@ struct TransactionListView: View {
guard let profile = bitcoinService.currentProfile else { return }
let count = LabelService.importBIP329(
data: data,
walletID: profile.id,
cosigners: profile.cosigners,
context: modelContext
)
do {
let count = try LabelService.importBIP329(
data: data,
walletID: profile.id,
cosigners: profile.cosigners,
context: modelContext
)
if count == 0 {
importResult = "No new labels found to import."
} else {
importResult = "Successfully imported \(count) label\(count == 1 ? "" : "s")."
if count == 0 {
importResult = "No new labels found to import."
} else {
importResult = "Successfully imported \(count) label\(count == 1 ? "" : "s")."
}
} catch {
logger.error("Failed to save imported labels: \(error.localizedDescription)")
importResult = "Failed to save imported labels."
}
showImportResult = true
@ -153,17 +165,22 @@ struct TransactionListView: View {
guard let profile = bitcoinService.currentProfile else { return }
let count = LabelService.importBIP329(
data: data,
walletID: profile.id,
cosigners: profile.cosigners,
context: modelContext
)
do {
let count = try LabelService.importBIP329(
data: data,
walletID: profile.id,
cosigners: profile.cosigners,
context: modelContext
)
if count == 0 {
importResult = "No new labels found to import."
} else {
importResult = "Successfully imported \(count) label\(count == 1 ? "" : "s")."
if count == 0 {
importResult = "No new labels found to import."
} else {
importResult = "Successfully imported \(count) label\(count == 1 ? "" : "s")."
}
} catch {
logger.error("Failed to save imported labels from QR: \(error.localizedDescription)")
importResult = "Failed to save imported labels."
}
showImportResult = true
}
@ -213,7 +230,11 @@ struct TransactionListView: View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 2) {
if fiatEnabled, fiatPrimary, let fiatStr = fiatService.formattedSatsToFiat(viewModel.balance) {
if isPrivate {
Text(Constants.privacyText())
.font(.hbAmountLarge)
.foregroundStyle(Color.hbTextPrimary)
} else if fiatEnabled, fiatPrimary, let fiatStr = fiatService.formattedSatsToFiat(viewModel.balance) {
Text(fiatStr)
.font(.hbAmountLarge)
.foregroundStyle(Color.hbTextPrimary)
@ -231,6 +252,9 @@ struct TransactionListView: View {
}
}
}
.onLongPressGesture {
togglePrivacyMode()
}
.onTapGesture(count: 2) {
if fiatEnabled { fiatPrimary.toggle() }
}
@ -375,12 +399,16 @@ struct TransactionListView: View {
.onChange(of: bitcoinService.syncState) { _, newState in
viewModel.updateFromService()
if case .synced = newState, let walletID = bitcoinService.currentProfile?.id {
LabelService.propagateAddressLabels(
transactions: bitcoinService.transactions,
utxos: bitcoinService.utxos,
context: modelContext,
walletID: walletID
)
do {
try LabelService.propagateAddressLabels(
transactions: bitcoinService.transactions,
utxos: bitcoinService.utxos,
context: modelContext,
walletID: walletID
)
} catch {
logger.error("Failed to propagate address labels after sync: \(error.localizedDescription)")
}
}
}
.onChange(of: BitcoinService.shared.currentProfile?.id) {
@ -389,9 +417,23 @@ struct TransactionListView: View {
}
}
private func togglePrivacyMode() {
guard let wallet = wallets.first(where: { $0.isActive }) else { return }
wallet.privacyMode.toggle()
try? modelContext.save()
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
}
@ViewBuilder
private var transactionContent: some View {
if viewModel.transactions.isEmpty {
if viewModel.transactions.isEmpty, viewModel.isLoading || viewModel.syncState.isSyncing {
ScrollView {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 150)
}
} else if viewModel.transactions.isEmpty {
ScrollView {
ContentUnavailableView(
"No Transactions",
@ -404,7 +446,7 @@ struct TransactionListView: View {
} else {
List(viewModel.transactions) { tx in
NavigationLink(destination: TransactionDetailView(transaction: tx, network: viewModel.network)) {
TransactionRowView(transaction: tx, label: txLabel(for: tx.id), showChevron: false, showFiat: fiatEnabled && fiatPrimary)
TransactionRowView(transaction: tx, label: txLabel(for: tx.id), showChevron: false, showFiat: fiatEnabled && fiatPrimary, isPrivate: isPrivate)
}
.listRowBackground(Color.hbSurface)
}

View File

@ -6,6 +6,7 @@ struct TransactionRowView: View {
var label: String?
var showChevron: Bool = true
var showFiat: Bool = false
var isPrivate: Bool = false
@AppStorage(Constants.denominationKey) private var denomination: String = "sats"
@State private var now = Date()
@ -40,7 +41,11 @@ struct TransactionRowView: View {
Spacer()
VStack(alignment: .trailing, spacing: 4) {
if showFiat, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(transaction.amount) {
if isPrivate {
Text(Constants.privacyText())
.font(.hbMono(14))
.foregroundStyle(transaction.isIncoming ? Color.hbSuccess : Color.hbTextPrimary)
} else if showFiat, let fiatStr = FiatPriceService.shared.formattedSatsToFiat(transaction.amount) {
Text(fiatStr)
.font(.hbMono(14))
.foregroundStyle(transaction.isIncoming ? Color.hbSuccess : Color.hbTextPrimary)

View File

@ -12,6 +12,10 @@ struct WalletDashboardView: View {
FiatPriceService.shared
}
private var isPrivate: Bool {
BitcoinService.shared.currentProfile?.privacyMode ?? false
}
private var transactions: [TransactionItem] {
bitcoinService.transactions
}
@ -81,10 +85,10 @@ struct WalletDashboardView: View {
Text("Total Balance")
.font(.hbLabel())
.foregroundStyle(Color.hbTextSecondary)
Text(confirmedBalance.formattedSats)
Text(isPrivate ? Constants.privacyText() : confirmedBalance.formattedSats)
.font(.hbAmountLarge)
.foregroundStyle(Color.hbTextPrimary)
if fiatEnabled, let fiat = fiatService.formattedSatsToFiat(confirmedBalance) {
if !isPrivate, fiatEnabled, let fiat = fiatService.formattedSatsToFiat(confirmedBalance) {
Text(fiat)
.font(.hbBody(15))
.foregroundStyle(Color.hbTextSecondary)
@ -97,7 +101,7 @@ struct WalletDashboardView: View {
HStack {
Image(systemName: "clock")
.font(.system(size: 11))
Text("\(mempoolBalance.formattedSats) unconfirmed")
Text(isPrivate ? "\(Constants.privacyText()) unconfirmed" : "\(mempoolBalance.formattedSats) unconfirmed")
.font(.hbLabel(12))
Spacer()
}
@ -133,12 +137,12 @@ struct WalletDashboardView: View {
DashboardMetricCard(
icon: "equal.circle",
label: "Avg UTXO Size",
value: avgUTXOSize.formattedSats
value: isPrivate ? Constants.privacyText() : avgUTXOSize.formattedSats
)
DashboardMetricCard(
icon: "creditcard",
label: "Total Fees Paid",
value: totalFeesPaid > 0 ? totalFeesPaid.formattedSats : ""
value: isPrivate ? Constants.privacyText() : (totalFeesPaid > 0 ? totalFeesPaid.formattedSats : "")
)
if let age = avgUTXOAge {
DashboardMetricCard(

View File

@ -44,6 +44,9 @@ struct DescriptorImportView: View {
.background(Color.hbSurfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))
.foregroundStyle(Color.hbTextPrimary)
.onChange(of: viewModel.importedDescriptorText) {
viewModel.importDescriptorError = nil
}
Text("Expected format: wsh(sortedmulti(M,[fp/path]xpub/0/*,...))")
.font(.hbMono(10))
@ -82,6 +85,18 @@ struct DescriptorImportView: View {
.padding(.horizontal, 24)
}
if let importError = viewModel.importDescriptorError {
Text(importError)
.font(.hbBody(13))
.foregroundStyle(Color.hbError)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.hbError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 24)
}
// Electrum server
ElectrumServerSetupSection(viewModel: viewModel)
.padding(.horizontal, 24)

View File

@ -34,7 +34,10 @@ struct SetupWizardView: View {
case .descriptorImport:
DescriptorImportView(viewModel: viewModel)
case .walletName:
WalletNameView(viewModel: viewModel)
WalletNameView(
viewModel: viewModel,
onSave: viewModel.creationMode == .importDescriptor ? saveAndFinish : nil
)
case .review:
WalletReviewView(viewModel: viewModel, onComplete: saveAndFinish)
}

View File

@ -2,6 +2,7 @@ import SwiftUI
struct WalletNameView: View {
@Bindable var viewModel: SetupWizardViewModel
var onSave: (() -> Void)?
var body: some View {
VStack(spacing: 32) {
@ -32,16 +33,24 @@ struct WalletNameView: View {
Spacer()
HStack(spacing: 16) {
Button(action: { viewModel.goBack() }) {
Text("Back")
.font(.hbBody(16))
.foregroundStyle(Color.hbTextSecondary)
if viewModel.creationMode == .createNew {
Button(action: { viewModel.goBack() }) {
Text("Back")
.font(.hbBody(16))
.foregroundStyle(Color.hbTextSecondary)
}
}
Spacer()
Button(action: { viewModel.goToNext() }) {
Text("Next")
Button(action: {
if viewModel.creationMode == .importDescriptor, let onSave {
onSave()
} else {
viewModel.goToNext()
}
}) {
Text(viewModel.creationMode == .importDescriptor ? "Create Wallet" : "Next")
.font(.hbHeadline)
.foregroundStyle(.white)
.padding(.horizontal, 32)

View File

@ -6,9 +6,25 @@ struct hellbenderApp: App {
let modelContainer: ModelContainer
init() {
#if DEBUG
if CommandLine.arguments.contains("-UITesting") {
// Clear UserDefaults so the app starts fresh (shows setup wizard)
if let bundleID = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleID)
}
// Clear Keychain (PIN, lockout state)
KeychainHelper.deleteAll()
}
#endif
do {
let schema = Schema([WalletProfile.self, CosignerInfo.self, WalletLabel.self, FrozenUTXO.self, SavedPSBT.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
#if DEBUG
let isUITesting = CommandLine.arguments.contains("-UITesting")
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: isUITesting)
#else
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
#endif
modelContainer = try ModelContainer(for: schema, configurations: [config])
BitcoinService.shared.modelContainer = modelContainer
} catch {

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct AddressDerivationTests {
@Test func addressPrefixTestnet() {
let network = BitcoinNetwork.testnet4

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct BumpFeeViewModelTests {
/// Helper to create a test transaction
private func makeTransaction(fee: UInt64 = 500, vsize: UInt64 = 200) -> TransactionItem {

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct CosignerValidationTests {
@Test func validFingerprint() {
let vm = SetupWizardViewModel()

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct DescriptorTests {
// MARK: - Descriptor Construction

View File

@ -1 +1 @@
cHNidP8BAIkCAAAAAXMXP6UjpG3LH9tyw5O5xSLAGtsag6BoVGvJr+PhDfVkAAAAAAD9////AhAnAAAAAAAAIgAgVKM5DK/YEuejdtxR6EEuFIzAB2ZnCjEjXb7E0vF1xQjyGQAAAAAAABYAFDX6Cav8kMYJXIMz40gPG5TrMjNGAAAAAAABAR8gTgAAAAAAABYAFJqBSLjJKbmcr3MqNkgL5JIz0dvtIgYDSsXaChoAAAAQEYy4xq8yyxr4qmJwByobLIPkNb8u99qCibDq+c9QDEchp5iV8iOPnBwKOq3P+tWwi4TqjvAXdPjgHHNbpYPfUBHMXaChpYh/QBo/UAAAA==
cHNidP8BAIkCAAAAAXMXP6UjpG3LH9tyw5O5xSLAGtsag6BoVGvJr+PhDfVkAAAAAAD9////AhAnAAAAAAAAIgAgVKM5DK/YEuejdtxR6EEuFIzAB2ZnCjEjXb7E0vF1xQjyGQAAAAAAABYAFDX6Cav8kMYJXIMz40gPG5TrMjNGAAAAAAABAR8gTgAAAAAAABYAFJqBSLjJKbmcr3MqNkgL5JIz0dvtIgYDSsXaChoAAAAQEYy4xq8yyxr4qmJwByobLIPkNb8u99qCibDq+c9QDEchp5iV8iOPnBwKOq3P+tWwi4TqjvAXdPjgHHNbpYPfUBHMXaChpYh/QBo/UAAAAA==

View File

@ -83,12 +83,12 @@ struct LabelServiceExportTests {
// MARK: - 2. Origin format
@Test func originFormat() {
@Test func originFormat() throws {
let records = Self.buildRecords(
transactions: [Self.makeTx(id: "abc123", amount: 1000)]
)
let txRecord = records.first { $0.type == "tx" }!
let origin = txRecord.origin!
let txRecord = try #require(records.first { $0.type == "tx" })
let origin = try #require(txRecord.origin)
#expect(origin.hasPrefix("wsh(sortedmulti(2,"))
#expect(origin.hasSuffix("))"))
#expect(origin.contains("48h/1h/0h/2h"))
@ -100,21 +100,21 @@ struct LabelServiceExportTests {
// MARK: - 3. Transaction with label
@Test func transactionWithLabel() {
@Test func transactionWithLabel() throws {
let label = Self.makeLabel(type: .tx, ref: "txid1", label: "Rent payment")
let tx = Self.makeTx(id: "txid1", amount: -50000, fee: 300, isIncoming: false, blockHeight: 800_100)
let records = Self.buildRecords(labels: [label], transactions: [tx])
let txRecord = records.first { $0.type == "tx" }!
let txRecord = try #require(records.first { $0.type == "tx" })
#expect(txRecord.label == "Rent payment")
#expect(txRecord.ref == "txid1")
}
// MARK: - 4. Transaction without label
@Test func transactionWithoutLabel() {
@Test func transactionWithoutLabel() throws {
let tx = Self.makeTx(id: "txid2", amount: 10000, fee: 150, blockHeight: 800_200)
let records = Self.buildRecords(transactions: [tx])
let txRecord = records.first { $0.type == "tx" }!
let txRecord = try #require(records.first { $0.type == "tx" })
#expect(txRecord.label == nil)
#expect(txRecord.height == 800_200)
#expect(txRecord.time != nil)
@ -124,30 +124,30 @@ struct LabelServiceExportTests {
// MARK: - 5. Height omitted when < 6 confirmations
@Test func transactionHeightOmittedUnderSixConfs() {
@Test func transactionHeightOmittedUnderSixConfs() throws {
let tx = Self.makeTx(id: "txid3", amount: 5000, confirmations: 3, blockHeight: 800_300)
let records = Self.buildRecords(transactions: [tx])
let txRecord = records.first { $0.type == "tx" }!
let txRecord = try #require(records.first { $0.type == "tx" })
#expect(txRecord.height == nil)
#expect(txRecord.time == nil)
}
// MARK: - 6. Transaction fee omitted when nil
@Test func transactionFeeOmittedWhenNil() {
@Test func transactionFeeOmittedWhenNil() throws {
let tx = Self.makeTx(id: "txid4", amount: 20000, fee: nil)
let records = Self.buildRecords(transactions: [tx])
let txRecord = records.first { $0.type == "tx" }!
let txRecord = try #require(records.first { $0.type == "tx" })
#expect(txRecord.fee == nil)
}
// MARK: - 7. Address with label
@Test func addressWithLabel() {
@Test func addressWithLabel() throws {
let addr = AddressItem(index: 5, address: "tb1qaddr5", isUsed: true, isChange: false)
let label = Self.makeLabel(type: .addr, ref: "tb1qaddr5", label: "Savings")
let records = Self.buildRecords(labels: [label], receiveAddresses: [addr])
let addrRecord = records.first { $0.type == "addr" }!
let addrRecord = try #require(records.first { $0.type == "addr" })
#expect(addrRecord.label == "Savings")
#expect(addrRecord.keypath == "/0/5")
#expect(addrRecord.origin != nil)
@ -155,10 +155,10 @@ struct LabelServiceExportTests {
// MARK: - 8. Address without label
@Test func addressWithoutLabel() {
@Test func addressWithoutLabel() throws {
let addr = AddressItem(index: 3, address: "tb1qaddr3", isUsed: false, isChange: false)
let records = Self.buildRecords(receiveAddresses: [addr])
let addrRecord = records.first { $0.type == "addr" }!
let addrRecord = try #require(records.first { $0.type == "addr" })
#expect(addrRecord.label == nil)
#expect(addrRecord.keypath == "/0/3")
#expect(addrRecord.heights == [])
@ -166,7 +166,7 @@ struct LabelServiceExportTests {
// MARK: - 9. Address heights
@Test func addressHeights() {
@Test func addressHeights() throws {
let addr = AddressItem(index: 0, address: "tb1qused", isUsed: true, isChange: false)
let tx1 = Self.makeTx(
id: "tx1", amount: 1000, confirmations: 10, blockHeight: 800_000,
@ -177,31 +177,31 @@ struct LabelServiceExportTests {
outputs: [TransactionItem.TxIO(address: "tb1qused", amount: 2000, prevTxid: nil, prevVout: nil, isMine: true)]
)
let records = Self.buildRecords(transactions: [tx1, tx2], receiveAddresses: [addr])
let addrRecord = records.first { $0.type == "addr" }!
let addrRecord = try #require(records.first { $0.type == "addr" })
#expect(addrRecord.heights == [800_000, 800_050])
}
// MARK: - 10. Unused address
@Test func unusedAddressEmptyHeights() {
@Test func unusedAddressEmptyHeights() throws {
let addr = AddressItem(index: 10, address: "tb1qunused", isUsed: false, isChange: false)
let records = Self.buildRecords(receiveAddresses: [addr])
let addrRecord = records.first { $0.type == "addr" }!
let addrRecord = try #require(records.first { $0.type == "addr" })
#expect(addrRecord.heights == [])
}
// MARK: - 11. Change address keypath
@Test func changeAddressKeypath() {
@Test func changeAddressKeypath() throws {
let addr = AddressItem(index: 7, address: "tb1qchange7", isUsed: true, isChange: true)
let records = Self.buildRecords(changeAddresses: [addr])
let addrRecord = records.first { $0.type == "addr" }!
let addrRecord = try #require(records.first { $0.type == "addr" })
#expect(addrRecord.keypath == "/1/7")
}
// MARK: - 12. Output record with label
@Test func outputRecordWithLabel() {
@Test func outputRecordWithLabel() throws {
let addr = AddressItem(index: 2, address: "tb1qout", isUsed: true, isChange: false)
let tx = Self.makeTx(
id: "txout1", amount: 5000, blockHeight: 800_500,
@ -209,7 +209,7 @@ struct LabelServiceExportTests {
)
let label = Self.makeLabel(type: .utxo, ref: "txout1:0", label: "KYC-free")
let records = Self.buildRecords(labels: [label], transactions: [tx], receiveAddresses: [addr])
let outputRecord = records.first { $0.type == "output" }!
let outputRecord = try #require(records.first { $0.type == "output" })
#expect(outputRecord.ref == "txout1:0")
#expect(outputRecord.label == "KYC-free")
#expect(outputRecord.keypath == "/0/2")
@ -218,53 +218,53 @@ struct LabelServiceExportTests {
// MARK: - 13. Unspent output spendable
@Test func unspentOutputSpendable() {
@Test func unspentOutputSpendable() throws {
let tx = Self.makeTx(
id: "txutxo1", amount: 3000,
outputs: [TransactionItem.TxIO(address: "tb1qutxo", amount: 3000, prevTxid: nil, prevVout: nil, isMine: true)]
)
let utxo = UTXOItem(txid: "txutxo1", vout: 0, amount: 3000, isConfirmed: true, keychain: .external)
let records = Self.buildRecords(transactions: [tx], utxos: [utxo])
let outputRecord = records.first { $0.type == "output" }!
let outputRecord = try #require(records.first { $0.type == "output" })
#expect(outputRecord.spendable == true)
}
// MARK: - 14. Frozen UTXO
@Test func frozenUtxoNotSpendable() {
@Test func frozenUtxoNotSpendable() throws {
let tx = Self.makeTx(
id: "txfrozen", amount: 4000,
outputs: [TransactionItem.TxIO(address: "tb1qfrozen", amount: 4000, prevTxid: nil, prevVout: nil, isMine: true)]
)
let utxo = UTXOItem(txid: "txfrozen", vout: 0, amount: 4000, isConfirmed: true, keychain: .external)
let records = Self.buildRecords(transactions: [tx], utxos: [utxo], frozenOutpoints: ["txfrozen:0"])
let outputRecord = records.first { $0.type == "output" }!
let outputRecord = try #require(records.first { $0.type == "output" })
#expect(outputRecord.spendable == false)
}
// MARK: - 15. Spent output
@Test func spentOutputNoSpendableField() {
@Test func spentOutputNoSpendableField() throws {
let tx = Self.makeTx(
id: "txspent", amount: 2000,
outputs: [TransactionItem.TxIO(address: "tb1qspent", amount: 2000, prevTxid: nil, prevVout: nil, isMine: true)]
)
// No matching UTXO output is spent
let records = Self.buildRecords(transactions: [tx])
let outputRecord = records.first { $0.type == "output" }!
let outputRecord = try #require(records.first { $0.type == "output" })
#expect(outputRecord.spendable == nil)
}
// MARK: - 16. Input record
@Test func inputRecord() {
@Test func inputRecord() throws {
let addr = AddressItem(index: 1, address: "tb1qinput", isUsed: true, isChange: false)
let tx = Self.makeTx(
id: "txspend", amount: -8000, fee: 200, isIncoming: false, blockHeight: 801_000,
inputs: [TransactionItem.TxIO(address: "tb1qinput", amount: 10000, prevTxid: "prevtx1", prevVout: 2, isMine: true)]
)
let records = Self.buildRecords(transactions: [tx], receiveAddresses: [addr])
let inputRecord = records.first { $0.type == "input" }!
let inputRecord = try #require(records.first { $0.type == "input" })
#expect(inputRecord.ref == "txspend:0")
#expect(inputRecord.keypath == "/0/1")
#expect(inputRecord.value == 10000)
@ -282,7 +282,7 @@ struct LabelServiceExportTests {
let utxo = UTXOItem(txid: "txjsonl", vout: 0, amount: 1000, isConfirmed: true, keychain: .external)
let records = Self.buildRecords(transactions: [tx], utxos: [utxo])
let data = BIP329Record.encodeToJSONL(records)
let text = String(data: data, encoding: .utf8)!
let text = try #require(String(data: data, encoding: .utf8))
let lines = text.split(separator: "\n")
// Each line should be valid JSON
@ -292,8 +292,8 @@ struct LabelServiceExportTests {
}
// Find the output record line and verify spendable is boolean
let outputLine = lines.first { $0.contains("\"type\":\"output\"") }!
let outputJSON = try JSONSerialization.jsonObject(with: Data(outputLine.utf8)) as! [String: Any]
let outputLine = try #require(lines.first { $0.contains("\"type\":\"output\"") })
let outputJSON = try #require(JSONSerialization.jsonObject(with: Data(outputLine.utf8)) as? [String: Any])
#expect(outputJSON["spendable"] is Bool)
#expect(outputJSON["spendable"] as? Bool == true)
}
@ -342,7 +342,8 @@ struct LabelServiceExportTests {
}
// Step 3: Decode the UR
let decodedUR = try decoder.result!.get()
let decodedResult = try #require(decoder.result)
let decodedUR = try decodedResult.get()
#expect(decodedUR.type == "bytes")
// Step 4: Extract bytes from CBOR (same as URService.processUR does)
@ -402,7 +403,8 @@ struct LabelServiceExportTests {
if iterations > 5000 { break }
}
let decodedUR = try decoder.result!.get()
let decodedResult2 = try #require(decoder.result)
let decodedUR = try decodedResult2.get()
guard case let .bytes(decodedData) = decodedUR.cbor else {
#expect(Bool(false), "Expected CBOR.bytes")
return
@ -423,7 +425,7 @@ struct LabelServiceExportTests {
#expect(xpubs.count == 2)
let txs = records.filter { $0.type == "tx" }
#expect(txs.count == 31)
#expect(txs.count == 30)
// Verify emoji labels survived encoding
let chrisTx = txs.first { $0.label == "Chris for 🍻" }

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
/// Configurable mock for testing ViewModels without BDK dependencies
@MainActor
final class MockBitcoinService: BitcoinServiceProtocol {
// MARK: - Properties

View File

@ -2,12 +2,13 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct PSBTTests {
// MARK: - Helpers
private func loadFixture(_ name: String) -> String? {
let bundle = Bundle(for: BundleToken.self)
guard let path = bundle.path(forResource: name, ofType: "txt", inDirectory: "Fixtures") else { return nil }
guard let path = bundle.path(forResource: name, ofType: "txt") else { return nil }
return try? String(contentsOfFile: path, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
}
@ -36,7 +37,7 @@ struct PSBTTests {
@Test func loadTestPSBTFixture() {
// Test that the fixture file can be loaded
let bundle = Bundle(for: BundleToken.self)
if let path = bundle.path(forResource: "test_psbt_unsigned", ofType: "txt", inDirectory: "Fixtures") {
if let path = bundle.path(forResource: "test_psbt_unsigned", ofType: "txt") {
let content = try? String(contentsOfFile: path, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(content != nil)
#expect(content?.isEmpty == false)

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct SendViewModelTests {
// MARK: - Initial State

View File

@ -2,6 +2,7 @@ import Foundation
@testable import hellbender
import Testing
@MainActor
struct SigningFlowTests {
// MARK: - Helpers

View File

@ -8,35 +8,120 @@
import XCTest
final class hellbenderUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
var app: XCUIApplication!
// In UI tests it is usually best to stop immediately when a failure occurs.
override func setUpWithError() throws {
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
app = XCUIApplication()
app.launchArguments = ["-UITesting"]
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
app = nil
}
// MARK: - Wallet Setup via Descriptor Import
@MainActor
func testExample() {
// UI tests must launch the application that they test.
let app = XCUIApplication()
func testSetupWalletViaDescriptorImport() {
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
// Step 1: Welcome screen tap "Get Started"
let getStarted = app.buttons["Get Started"]
XCTAssertTrue(getStarted.waitForExistence(timeout: 5), "Welcome screen should show 'Get Started' button")
getStarted.tap()
@MainActor
func testLaunchPerformance() {
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
// Step 2: Creation choice tap "Import Descriptor" card
let importCard = app.staticTexts["Import Descriptor"]
XCTAssertTrue(importCard.waitForExistence(timeout: 3), "Creation choice should show 'Import Descriptor' option")
importCard.tap()
// Step 3: Descriptor import screen
let importTitle = app.staticTexts["Import Descriptor"]
XCTAssertTrue(importTitle.waitForExistence(timeout: 3), "Descriptor import screen should appear")
// Type a valid 1-of-2 testnet4 descriptor into the TextEditor
let testDescriptor = "wsh(sortedmulti(1,[7a13a7b1/48'/1'/0'/2']tpubDETciRzaZyqww2dSAyT2j6tWgzREyiZEY2iZDPKDtqNpSEqqFS31DZUFFTFnayx7wLUVYx3V1R2AWhhWbFrnCukKZ1kmnn83Fn2xSf7hEaH/<0;1>/*,[30a36b52/48'/1'/0'/2']tpubDF6MPv2vWsbCo8c7rk4X32BPa5yuj4niem5Pr6isrd9cSdCkYETcGUmBSFY4ekTR1CRFmjn4eoYGrwPU19FffwEpX7Tda6BBmg91aiHKpmE/<0;1>/*))"
// Type the descriptor into the TextEditor
let textEditor = app.textViews.firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 3), "Descriptor text editor should exist")
textEditor.tap()
textEditor.typeText(testDescriptor)
// Select "Testnet4" in the network segmented picker (should already be selected as default,
// but tap it to be explicit)
let testnet4Button = app.buttons["Testnet4"]
if testnet4Button.waitForExistence(timeout: 2) {
testnet4Button.tap()
}
// Tap "Import" to parse the descriptor and advance
let importButton = app.buttons["Import"]
XCTAssertTrue(importButton.waitForExistence(timeout: 3), "Import button should exist")
importButton.tap()
// Step 4: Wallet name screen
let nameTitle = app.staticTexts["Name Your Wallet"]
XCTAssertTrue(nameTitle.waitForExistence(timeout: 3), "Wallet name screen should appear")
// Type a wallet name into the text field
let nameField = app.textFields["My Multisig Wallet"]
XCTAssertTrue(nameField.waitForExistence(timeout: 3), "Wallet name text field should exist")
nameField.tap()
nameField.typeText("UI Test Wallet")
// Tap "Next" to go to review
let nextButton = app.buttons["Next"]
XCTAssertTrue(nextButton.exists, "Next button should exist")
nextButton.tap()
// Step 5: Review screen
let reviewTitle = app.staticTexts["Review Wallet"]
XCTAssertTrue(reviewTitle.waitForExistence(timeout: 3), "Review screen should appear")
// Verify wallet details shown on review screen
XCTAssertTrue(app.staticTexts["UI Test Wallet"].exists, "Wallet name should appear in review")
XCTAssertTrue(app.staticTexts["1-of-2 Multisig"].exists, "Multisig type should appear in review")
XCTAssertTrue(app.staticTexts["Testnet4"].exists, "Network should appear in review")
XCTAssertTrue(app.staticTexts["P2WSH (Native Segwit)"].exists, "Script type should appear in review")
// Verify cosigner fingerprints are displayed
XCTAssertTrue(app.staticTexts["7a13a7b1"].exists, "First cosigner fingerprint should appear")
XCTAssertTrue(app.staticTexts["30a36b52"].exists, "Second cosigner fingerprint should appear")
// Tap "Create Wallet" to finish
let createButton = app.buttons["Create Wallet"]
XCTAssertTrue(createButton.exists, "Create Wallet button should exist")
createButton.tap()
// Verify we land on the main transaction screen (wallet loaded with transactions)
let balanceExists = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'sats'")).firstMatch
XCTAssertTrue(balanceExists.waitForExistence(timeout: 10), "Main screen should appear after wallet creation")
// Wait for sync to complete before navigating
sleep(10)
// Step 6: Navigate to Receive tab
let receiveTab = app.tabBars.buttons["Receive"]
XCTAssertTrue(receiveTab.waitForExistence(timeout: 5), "Receive tab should exist")
receiveTab.tap()
// Wait for the receive screen to load with an address
let viewAllAddresses = app.buttons["View All Addresses"]
XCTAssertTrue(viewAllAddresses.waitForExistence(timeout: 10), "View All Addresses link should appear on Receive screen")
// Step 7: Tap "View All Addresses" to open the address list
viewAllAddresses.tap()
// Wait for the address list to load
let addressesTitle = app.navigationBars["Addresses"]
XCTAssertTrue(addressesTitle.waitForExistence(timeout: 10), "Addresses screen should appear")
// Step 8: Verify the first address (#0) matches the expected address
let expectedAddress = "tb1q8xp3nj85dg02yqzhwslyhdrjxg722mrnawv6cwz7xj4jtxs5j0us8n0eyq"
let firstAddressCell = app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", expectedAddress)).firstMatch
XCTAssertTrue(firstAddressCell.waitForExistence(timeout: 10), "First address should be \(expectedAddress)")
}
}