356 lines
12 KiB
Swift
356 lines
12 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?
|
|
/// 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
|
|
|
|
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: - 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
|
|
}
|
|
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
|
|
errorMessage = nil
|
|
statusMessage = nil
|
|
balance = Balance(total: "0", unlocked: "", funded: false)
|
|
transactions = []
|
|
syncStatus = nil
|
|
|
|
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 }
|
|
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)
|
|
balance = try await balanceCall
|
|
transactions = try await txCall
|
|
} catch {
|
|
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.
|
|
updateSyncStatus(try await MoneroService.shared.syncStatus())
|
|
refreshTick += 1
|
|
await checkpointWalletIfSyncing()
|
|
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
|
async let balanceCall = MoneroService.shared.balance()
|
|
async let txCall = MoneroService.shared.transactions()
|
|
balance = try await balanceCall
|
|
transactions = try await txCall
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
} |