588 lines
21 KiB
Swift
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()
|
|
}
|
|
} |