import Foundation import SwiftUI @MainActor final class AppModel: ObservableObject { @Published var chain: Chain = .xrp @Published var xrpWallet: WalletRecord? @Published var xmrWallet: WalletRecord? @Published var balance = Balance(total: "0", unlocked: "", funded: false) @Published var transactions: [TransactionRecord] = [] @Published var syncStatus: SyncStatus? @Published var isBusy = false @Published var errorMessage: String? @Published var statusMessage: String? @Published var moneroReady = false @Published var moneroAddress: String? @Published var moneroAccounts: [MoneroAccount] = [] /// Rough time remaining for the Monero sync, derived from observed block rate. @Published var syncEta: String? private var moneroOpened = false private var refreshTick = 0 private var refreshTask: Task? private var lastSample: (height: UInt64, date: Date)? private var lastCheckpoint = Date.distantPast /// Invalidates in-flight refreshes: switching chains (or starting a newer /// refresh) must stop an older request from writing its result to the UI. private var refreshToken = UUID() var wallet: WalletRecord? { chain == .xrp ? xrpWallet : xmrWallet } var hasWallet: Bool { wallet != nil } /// Address for the chain currently on screen. Kept chain-aware because the /// Monero address stays cached once its wallet is open. var displayAddress: String { chain == .xrp ? (xrpWallet?.address ?? "") : (moneroAddress ?? xmrWallet?.address ?? "") } var symbol: String { chain.symbol } // MARK: - Cache /// Restores the last-known state for the chain on screen instantly; the /// refresh that follows replaces it with live data. private func showCached(_ forChain: Chain) { guard let cached = WalletStore.cachedState(forChain) else { return } balance = cached.balance transactions = cached.transactions if let accounts = cached.accounts { moneroAccounts = accounts } } private func persistState(for forChain: Chain) { guard (forChain == .xrp ? xrpWallet : xmrWallet) != nil else { return } WalletStore.saveCachedState( ChainCache(balance: balance, transactions: transactions, accounts: forChain == .xmr ? moneroAccounts : nil, savedAt: Date()), chain: forChain) } // MARK: - Bootstrap func bootstrap() async { xrpWallet = WalletStore.wallet(.xrp) xmrWallet = WalletStore.wallet(.xmr) if xmrWallet == nil, let orphan = MoneroService.orphanedWalletNames().first { // Wallet file on disk with no stored record (for example after the // preferences were lost): re-attach it so funds are not stranded. await adoptMoneroWallet(named: orphan) } if let xmrWallet { await openMonero(afterRestore: false, wallet: xmrWallet) } if xrpWallet != nil || moneroReady { chain = xrpWallet != nil ? .xrp : .xmr } showCached(chain) await refresh(force: true) startAutoRefresh() } private func adoptMoneroWallet(named name: String) async { do { let address = try await MoneroService.shared.openWallet(name: name) let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))", name: name, address: address, createdAt: Date()) xmrWallet = record WalletStore.saveWallet(record, chain: .xmr) moneroOpened = true moneroReady = true moneroAddress = address statusMessage = "Recovered the existing Monero wallet “\(name)” from this device." } catch { errorMessage = "Found a Monero wallet file but could not open it: \(error.localizedDescription)" } } /// Polls the wallet while the app is in the foreground. Monero sync progress /// changes constantly, so it is refreshed far more often than XRP. func startAutoRefresh() { guard refreshTask == nil else { return } refreshTask = Task { [weak self] in while !Task.isCancelled { guard let self else { return } let seconds: UInt64 = self.chain == .xmr ? 5 : 30 try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) if Task.isCancelled { return } await self.refresh() } } } func select(_ newChain: Chain) async { chain = newChain refreshToken = UUID() errorMessage = nil statusMessage = nil balance = Balance(total: "0", unlocked: "", funded: false) transactions = [] moneroAccounts = [] syncStatus = nil showCached(newChain) if newChain == .xmr, let xmrWallet, !moneroOpened { await openMonero(afterRestore: false, wallet: xmrWallet) } await refresh(force: true) } // MARK: - Monero lifecycle private func openMonero(afterRestore: Bool, wallet: WalletRecord) async { isBusy = true defer { isBusy = false } do { let address = try await MoneroService.shared.openWallet(name: wallet.name) moneroOpened = true moneroReady = true moneroAddress = address.isEmpty ? wallet.address : address if let address = moneroAddress, address != wallet.address { var updated = wallet updated.address = address xmrWallet = updated WalletStore.saveWallet(updated, chain: .xmr) } // Always try to catch up, not only right after a restore. _ = try? await MoneroService.shared.startSync() } catch { // Keep the stored wallet so a transient daemon failure is not fatal. moneroOpened = false moneroReady = false moneroAddress = nil errorMessage = "Monero wallet could not be opened: \(error.localizedDescription)" } } // MARK: - Create / import func createWallet(name: String) async throws -> String { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw XRPError.rpc("Enter a wallet name") } isBusy = true defer { isBusy = false } if chain == .xrp { let (record, seed) = try XRPWalletService.shared.createWallet(name: trimmed) xrpWallet = record await refresh() return seed } let result = try await MoneroService.shared.createWallet(name: trimmed) let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))", name: trimmed, address: result.address, createdAt: Date()) xmrWallet = record WalletStore.saveWallet(record, chain: .xmr) if !result.mnemonic.isEmpty { WalletStore.saveSecret(result.mnemonic, chain: .xmr) } moneroOpened = true moneroReady = true moneroAddress = result.address _ = try? await MoneroService.shared.startSync() await refresh(force: true) return result.mnemonic } func importWallet(name: String, secret: String, restoreHeight: UInt64?) async throws { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { throw XRPError.rpc("Enter a wallet name") } guard !trimmedSecret.isEmpty else { throw XRPError.rpc("Enter your seed or secret") } isBusy = true defer { isBusy = false } if chain == .xrp { xrpWallet = try XRPWalletService.shared.importWallet(name: trimmedName, secret: trimmedSecret) await refresh(force: true) return } let address = try await MoneroService.shared.restoreWallet(name: trimmedName, seed: trimmedSecret, restoreHeight: restoreHeight ?? 0) let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))", name: trimmedName, address: address, createdAt: Date()) xmrWallet = record WalletStore.saveWallet(record, chain: .xmr) WalletStore.saveSecret(trimmedSecret, chain: .xmr) moneroOpened = true moneroReady = true moneroAddress = address _ = try? await MoneroService.shared.startSync() await refresh(force: true) } // MARK: - Reads func refresh(force: Bool = false) async { guard hasWallet else { return } let token = UUID() refreshToken = token let requestChain = chain errorMessage = nil if chain == .xrp, let wallet = xrpWallet { do { async let balanceCall = XRPWalletService.shared.balance(address: wallet.address) async let txCall = XRPWalletService.shared.transactions(address: wallet.address) let fetchedBalance = try await balanceCall let fetchedTransactions = try await txCall guard !isStale(token, requestChain) else { return } balance = fetchedBalance transactions = fetchedTransactions persistState(for: requestChain) } catch { guard !isStale(token, requestChain) else { return } errorMessage = error.localizedDescription } // The Monero wallet keeps scanning while XRP is on screen, so keep its // progress current here too. if moneroReady, let status = try? await MoneroService.shared.syncStatus() { updateSyncStatus(status) await checkpointWalletIfSyncing() } return } guard moneroReady else { return } do { // Sync progress is cheap and polled every tick; the balance and transfer // list touch the wallet's data files, so they are refreshed less often. let status = try await MoneroService.shared.syncStatus() if !isStale(token, requestChain) { updateSyncStatus(status) } refreshTick += 1 await checkpointWalletIfSyncing() if force || refreshTick == 1 || refreshTick % 4 == 0 { async let balanceCall = MoneroService.shared.balance() async let txCall = MoneroService.shared.transactions() async let accountsCall = MoneroService.shared.accounts() let fetchedBalance = try await balanceCall let fetchedTransactions = try await txCall let fetchedAccounts = try await accountsCall guard !isStale(token, requestChain) else { return } balance = fetchedBalance transactions = fetchedTransactions moneroAccounts = fetchedAccounts persistState(for: requestChain) } } catch { guard !isStale(token, requestChain) else { return } errorMessage = error.localizedDescription } } /// True when a newer refresh has started or the user moved to another chain /// while this request was in flight. private func isStale(_ token: UUID, _ requestChain: Chain) -> Bool { token != refreshToken || requestChain != chain } /// Tracks how fast blocks are being scanned so the UI can show an estimate. private func updateSyncStatus(_ status: SyncStatus) { syncStatus = status let now = Date() defer { lastSample = (status.height, now) } guard status.syncing, let sample = lastSample, status.height > sample.height, status.daemonHeight > status.height else { if !status.syncing { syncEta = nil } return } let elapsed = now.timeIntervalSince(sample.date) guard elapsed > 1 else { return } let blocksPerSecond = Double(status.height - sample.height) / elapsed guard blocksPerSecond > 0 else { return } let remaining = Double(status.daemonHeight - status.height) / blocksPerSecond syncEta = Self.formatDuration(remaining) } private static func formatDuration(_ seconds: Double) -> String { guard seconds.isFinite, seconds > 0 else { return "unknown" } let total = Int(seconds.rounded()) let hours = total / 3600 let minutes = (total % 3600) / 60 if hours >= 24 { return "\(hours / 24)d \(hours % 24)h" } if hours > 0 { return "\(hours)h \(minutes)m" } if minutes > 0 { return "\(minutes)m" } return "under a minute" } func startMoneroSync() async { guard moneroReady else { if let wallet = xmrWallet { await openMonero(afterRestore: true, wallet: wallet) } return } _ = try? await MoneroService.shared.startSync() await refresh(force: true) } func revealSeed() async throws -> String { if chain == .xrp { guard let seed = WalletStore.secret(.xrp) else { throw XRPError.rpc("No stored secret for this wallet") } return seed } if let stored = WalletStore.secret(.xmr), !stored.isEmpty { return stored } return try await MoneroService.shared.seed() } // MARK: - Sends func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws { let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces)) if chain == .xrp { guard let seed = WalletStore.secret(.xrp) else { throw XRPError.rpc("Wallet secret is unavailable") } statusMessage = nil _ = try await XRPWalletService.shared.send(secret: seed, to: to, amountXRP: amount, destinationTag: destinationTag) statusMessage = "Payment sent" await refresh(force: true) return } _ = try await MoneroService.shared.send(address: to, amountXMR: amount, priority: priority) statusMessage = "Payment sent" await refresh(force: true) } // MARK: - Delete func deleteWallet() async { if chain == .xrp { WalletStore.removeWallet(.xrp) xrpWallet = nil } else { if let wallet = xmrWallet { await MoneroService.shared.removeWallet(name: wallet.name) } WalletStore.removeWallet(.xmr) xmrWallet = nil moneroOpened = false moneroReady = false moneroAddress = nil syncStatus = nil } balance = Balance(total: "0", unlocked: "", funded: false) transactions = [] errorMessage = nil statusMessage = nil } func clearMessages() { errorMessage = nil statusMessage = nil } /// Called when the app leaves the foreground: iOS can suspend or kill it at /// any point, so flush the wallet while there is still time. func persistWallet() async { guard moneroReady else { return } await MoneroService.shared.save() } func addAccount(label: String = "") async throws { _ = try await MoneroService.shared.createAccount(label: label) await refresh(force: true) } func addSubaddress(accountIndex: Int, label: String = "") async throws { _ = try await MoneroService.shared.createSubaddress(accountIndex: accountIndex, label: label) await refresh(force: true) } /// Refreshes the account/subaddress tree independently of the active chain. func loadAccounts() async { guard moneroReady else { return } if let accounts = try? await MoneroService.shared.accounts() { moneroAccounts = accounts } } /// monero-cpp only writes the wallet file from save(), and its sync loop /// never does, so without this a restart re-scans from the beginning. private func checkpointWalletIfSyncing() async { guard moneroReady, syncStatus?.syncing == true else { return } guard Date().timeIntervalSince(lastCheckpoint) >= 60 else { return } lastCheckpoint = Date() await MoneroService.shared.save() } }