Files
koins/apps/ios/Koins/AppModel.swift
T

588 lines
21 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] = []
@Published var prices = PriceService.Prices(currency: "USD")
/// 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: - Fiat display
/// The fiat currency the wallet shows; persisted, and changing it refetches
/// the price cache.
var fiatCurrency: String {
get { UserDefaults.standard.string(forKey: "koins.fiatCurrency") ?? "USD" }
set {
UserDefaults.standard.set(newValue, forKey: "koins.fiatCurrency")
objectWillChange.send()
Task { await refreshPrices() }
}
}
/// The current chain's balance in the chosen fiat currency, when a price is
/// known.
var fiatBalance: String? {
fiatValue(of: balance.total, chain: chain)
}
/// Converts a chain amount into the chosen fiat currency, when a price is
/// known.
func fiatValue(of amount: String, chain: Chain) -> String? {
let price = chain == .xrp ? prices.xrp : prices.xmr
guard let price, let value = Decimal(string: amount) else { return nil }
let fiat = value * Decimal(price)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = fiatCurrency
formatter.locale = Locale(identifier: "en_US")
return formatter.string(from: fiat as NSDecimalNumber)
}
private func refreshPrices() async {
prices = await PriceService.shared.prices(currency: fiatCurrency)
}
// MARK: - Biometric lock
// MARK: - Swaps (kyc.rip)
@Published var swapOrders: [SwapOrder] = []
func loadSwapOrders() {
swapOrders = WalletStore.swapOrders()
}
func estimateSwap(amount: String, from: Chain, to: Chain) async throws -> SwapEstimate {
guard let amountNumber = Double(amount.trimmingCharacters(in: .whitespaces)),
amountNumber > 0 else {
throw SwapError.api("Enter a valid amount")
}
return try await SwapService.shared.estimate(from: from.rawValue, to: to.rawValue, amount: amountNumber)
}
func createSwap(route: SwapRoute, estimateID: String, amountFrom: String,
from: Chain, to: Chain) async throws -> SwapOrder {
guard let amountNumber = Double(amountFrom.trimmingCharacters(in: .whitespaces)), amountNumber > 0 else {
throw SwapError.api("Enter a valid amount")
}
guard let destination = (to == .xrp ? xrpWallet?.address : xmrWallet?.address), !destination.isEmpty else {
throw SwapError.api("No \(to.title) wallet to receive into")
}
let order = try await SwapService.shared.createOrder(route: route,
estimateID: estimateID,
from: from.rawValue,
to: to.rawValue,
addressTo: destination,
amountFrom: amountNumber)
WalletStore.saveSwapOrder(order)
loadSwapOrders()
return order
}
/// Sends the swap deposit using the same payment path as a normal send.
func sendSwapDeposit(_ order: SwapOrder, accountIndex: Int = 0) async throws {
try await send(to: order.depositAddress,
amount: String(order.depositAmount),
tag: order.depositMemo ?? "",
priority: .defaultPriority,
accountIndex: accountIndex)
}
func refreshSwapStatus(_ order: SwapOrder) async -> SwapOrder {
guard let updated = try? await SwapService.shared.orderStatus(order) else { return order }
var order = order
order.status = updated.status
order.txIn = updated.txIn ?? order.txIn
order.txOut = updated.txOut ?? order.txOut
if order.txIn?.isEmpty == true { order.txIn = nil }
if order.txOut?.isEmpty == true { order.txOut = nil }
order.supportUrl = updated.supportUrl ?? order.supportUrl
if let index = swapOrders.firstIndex(where: { $0.id == order.id }) {
swapOrders[index] = order
}
WalletStore.saveSwapOrder(order)
return order
}
/// Persists a local change to an order (for example, the deposit was sent).
func saveSwapOrder(_ order: SwapOrder) {
WalletStore.saveSwapOrder(order)
loadSwapOrders()
}
func removeSwapOrder(id: String) {
WalletStore.removeSwapOrder(id: id)
loadSwapOrders()
}
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)
}
// Restore the chain the user was last on, as long as that wallet exists.
let savedChain = UserDefaults.standard.string(forKey: "koins.chain").flatMap(Chain.init(rawValue:))
if let savedChain, (savedChain == .xrp ? xrpWallet : xmrWallet) != nil {
chain = savedChain
} else if xrpWallet != nil || moneroReady {
chain = xrpWallet != nil ? .xrp : .xmr
}
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
locked = true
}
loadSwapOrders()
showCached(chain)
await refresh(force: true)
startAutoRefresh()
Task { await refreshPrices() }
}
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
UserDefaults.standard.set(newChain.rawValue, forKey: "koins.chain")
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
await refreshPrices()
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,
accountIndex: Int = 0) 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,
accountIndex: accountIndex)
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)
}
/// The account a payment should leave from by default: the first one with
/// spendable funds, otherwise the primary account.
var defaultMoneroAccountIndex: Int {
moneroAccounts.first { (Decimal(string: $0.unlocked) ?? 0) > 0 }?.index ?? 0
}
/// 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()
}
}