461 lines
16 KiB
Swift
461 lines
16 KiB
Swift
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] = []
|
|
/// Whether the app is behind the biometric lock screen right now.
|
|
@Published var locked = false
|
|
|
|
/// Face ID/Touch ID lock. On by default when the device has biometrics;
|
|
/// persisted across launches.
|
|
var faceIDEnabled: Bool {
|
|
get { UserDefaults.standard.object(forKey: "koins.faceID") as? Bool ?? true }
|
|
set {
|
|
UserDefaults.standard.set(newValue, forKey: "koins.faceID")
|
|
objectWillChange.send()
|
|
}
|
|
}
|
|
/// 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<Void, Never>?
|
|
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: - Biometric lock
|
|
|
|
func authenticate() async {
|
|
if await Biometrics.authenticate(reason: "Unlock Koins") {
|
|
locked = false
|
|
}
|
|
}
|
|
|
|
/// Requires biometrics for sensitive actions when the lock is enabled; a
|
|
/// device without biometrics is not asked (the wallet is still usable).
|
|
func requireAuth(reason: String) async throws {
|
|
guard faceIDEnabled, Biometrics.isAvailable else { return }
|
|
let ok = await Biometrics.authenticate(reason: reason)
|
|
if !ok { throw XRPError.rpc("Authentication failed") }
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
|
|
locked = true
|
|
}
|
|
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 {
|
|
try await requireAuth(reason: "Reveal your recovery phrase")
|
|
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 {
|
|
try await requireAuth(reason: "Authorize this payment")
|
|
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()
|
|
}
|
|
} |