import Foundation /// Monero amounts are atomic units (1 XMR = 1e12 piconero). enum MoneroAmount { static let atomicPerXMR: UInt64 = 1_000_000_000_000 static func xmr(fromAtomic value: String) -> String { guard let atomic = UInt64(value) else { return "0" } return xmr(fromAtomic: atomic) } static func xmr(fromAtomic value: UInt64) -> String { let whole = value / atomicPerXMR let fraction = value % atomicPerXMR if fraction == 0 { return String(whole) } var text = String(format: "%012llu", fraction) while text.hasSuffix("0") { text.removeLast() } return "\(whole).\(text)" } static func atomic(fromXMR value: String) throws -> UInt64 { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !text.hasPrefix("-") else { throw XRPError.rpc("Enter a valid amount") } let parts = text.split(separator: ".", omittingEmptySubsequences: false) guard parts.count <= 2 else { throw XRPError.rpc("Enter a valid amount") } guard let whole = UInt64(parts[0].isEmpty ? "0" : String(parts[0])) else { throw XRPError.rpc("Enter a valid amount") } var fraction: UInt64 = 0 if parts.count == 2 { let fractionText = String(parts[1]) guard fractionText.count <= 12, fractionText.allSatisfy({ $0.isNumber }) else { throw XRPError.rpc("XMR supports at most 12 decimal places") } let padded = fractionText.padding(toLength: 12, withPad: "0", startingAt: 0) guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") } fraction = parsed } let (wholeAtomic, overflow) = whole.multipliedReportingOverflow(by: atomicPerXMR) guard !overflow else { throw XRPError.rpc("Amount is too large") } let (total, carry) = wholeAtomic.addingReportingOverflow(fraction) guard !carry else { throw XRPError.rpc("Amount is too large") } return total } } /// Swift wrapper around the Objective-C++ monero-cpp bridge. final class MoneroService { static let shared = MoneroService() /// Wallet files that exist on disk without a stored record, so a lost /// preference file does not strand a wallet. static func orphanedWalletNames() -> [String] { let manager = FileManager.default guard let documents = manager.urls(for: .documentDirectory, in: .userDomainMask).first, let items = try? manager.contentsOfDirectory(at: documents, includingPropertiesForKeys: nil) else { return [] } let keys = Set(items.filter { $0.pathExtension == "keys" } .map { $0.deletingPathExtension().lastPathComponent }) return items .filter { $0.pathExtension.isEmpty && !$0.lastPathComponent.hasPrefix(".") } .map { $0.lastPathComponent } .filter { keys.contains($0) } .sorted() } /// The wallet file is encrypted with this password; the recovery seed lives /// in the Keychain. static let walletPassword = "koins" /// Default daemon, kept in sync with the bridge. Used for the cheap /// daemon-only queries that do not need an open wallet. static let daemonHost = "xmr-node.cakewallet.com" /// Current chain tip, so an import can start scanning from now instead of /// from the genesis block. func daemonHeight() async throws -> UInt64 { guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else { throw XRPError.network("Invalid Monero daemon address") } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 15 request.httpBody = try JSONSerialization.data(withJSONObject: [ "jsonrpc": "2.0", "id": "koins", "method": "get_info", ]) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw XRPError.network("Monero daemon is unreachable") } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let result = json["result"] as? [String: Any], let height = (result["height"] as? NSNumber)?.uint64Value else { throw XRPError.network("Could not read the Monero chain tip") } return height } private func invoke(_ request: [String: Any]) async throws -> [String: Any] { let json = try JSONSerialization.data(withJSONObject: request) let requestString = String(data: json, encoding: .utf8) ?? "{}" let responseString: String = await withCheckedContinuation { continuation in KoinsMoneroWallet.invoke(requestString) { response in continuation.resume(returning: response) } } guard let data = responseString.data(using: .utf8), let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw XRPError.rpc("Unexpected response from the Monero wallet") } if let error = response["error"] as? String, !error.isEmpty { throw XRPError.rpc(error) } return response } // MARK: - Wallet lifecycle func createWallet(name: String) async throws -> (address: String, mnemonic: String) { let response = try await invoke(["op": "createWallet", "name": name, "password": Self.walletPassword, "language": "English"]) return (response["address"] as? String ?? "", response["mnemonic"] as? String ?? "") } func openWallet(name: String) async throws -> String { let response = try await invoke(["op": "openWallet", "name": name, "password": Self.walletPassword]) return response["address"] as? String ?? "" } func restoreWallet(name: String, seed: String, restoreHeight: UInt64) async throws -> String { let response = try await invoke(["op": "restoreWallet", "name": name, "password": Self.walletPassword, "seed": seed, "restoreHeight": restoreHeight]) return response["address"] as? String ?? "" } func removeWallet(name: String) async { _ = try? await invoke(["op": "removeWallet", "name": name]) } func closeWallet() async { _ = try? await invoke(["op": "closeWallet"]) } /// Writes the wallet file, which is how scan progress survives a restart. func save() async { _ = try? await invoke(["op": "saveWallet"]) } func seed() async throws -> String { let response = try await invoke(["op": "getSeed"]) return response["mnemonic"] as? String ?? "" } // MARK: - Reads func balance() async throws -> Balance { let response = try await invoke(["op": "getBalance"]) let total = response["balance"] as? String ?? "0" let unlocked = response["unlocked"] as? String ?? total return Balance(total: MoneroAmount.xmr(fromAtomic: total), unlocked: MoneroAmount.xmr(fromAtomic: unlocked), funded: (UInt64(total) ?? 0) > 0) } func transactions() async throws -> [TransactionRecord] { let response = try await invoke(["op": "getTransactions"]) let entries = response["transactions"] as? [[String: Any]] ?? [] return entries.map { entry in let timestamp = (entry["timestamp"] as? NSNumber)?.doubleValue ?? 0 return TransactionRecord( id: entry["hash"] as? String ?? UUID().uuidString, amount: MoneroAmount.xmr(fromAtomic: entry["amount"] as? String ?? "0"), timestamp: timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil, direction: (entry["direction"] as? String) == "in" ? .incoming : .outgoing, confirmed: (entry["confirmed"] as? Bool) ?? false, fee: MoneroAmount.xmr(fromAtomic: entry["fee"] as? String ?? "0"), tag: nil, counterparty: entry["counterparty"] as? String, confirmations: (entry["confirmations"] as? NSNumber)?.uint64Value, height: (entry["height"] as? NSNumber)?.uint64Value, paymentId: (entry["paymentId"] as? String).flatMap { $0.isEmpty ? nil : $0 }, accountIndex: (entry["accountIndex"] as? NSNumber)?.intValue, subaddressIndex: (entry["subaddressIndex"] as? NSNumber)?.intValue, note: (entry["note"] as? String).flatMap { $0.isEmpty ? nil : $0 }, weight: (entry["weight"] as? NSNumber)?.uint64Value) } } // MARK: - Accounts func accounts() async throws -> [MoneroAccount] { let response = try await invoke(["op": "getAccounts"]) let entries = response["accounts"] as? [[String: Any]] ?? [] return entries.map { entry in let subEntries = entry["subaddresses"] as? [[String: Any]] ?? [] return MoneroAccount( index: (entry["index"] as? NSNumber)?.intValue ?? 0, address: entry["address"] as? String ?? "", balance: MoneroAmount.xmr(fromAtomic: entry["balance"] as? String ?? "0"), unlocked: MoneroAmount.xmr(fromAtomic: entry["unlocked"] as? String ?? "0"), subaddresses: subEntries.map { sub in MoneroSubaddress( accountIndex: (sub["accountIndex"] as? NSNumber)?.intValue ?? 0, index: (sub["index"] as? NSNumber)?.intValue ?? 0, address: sub["address"] as? String ?? "", label: sub["label"] as? String ?? "", balance: MoneroAmount.xmr(fromAtomic: sub["balance"] as? String ?? "0"), unlocked: MoneroAmount.xmr(fromAtomic: sub["unlocked"] as? String ?? "0"), isUsed: (sub["isUsed"] as? Bool) ?? false) }) } } func createAccount(label: String) async throws -> MoneroAccount { let response = try await invoke(["op": "createAccount", "label": label]) return MoneroAccount(index: (response["index"] as? NSNumber)?.intValue ?? 0, address: response["address"] as? String ?? "", balance: MoneroAmount.xmr(fromAtomic: response["balance"] as? String ?? "0"), unlocked: MoneroAmount.xmr(fromAtomic: response["unlocked"] as? String ?? "0"), subaddresses: []) } func createSubaddress(accountIndex: Int, label: String) async throws -> MoneroSubaddress { let response = try await invoke(["op": "createSubaddress", "accountIndex": accountIndex, "label": label]) return MoneroSubaddress(accountIndex: (response["accountIndex"] as? NSNumber)?.intValue ?? accountIndex, index: (response["index"] as? NSNumber)?.intValue ?? 0, address: response["address"] as? String ?? "", label: response["label"] as? String ?? label, balance: "0", unlocked: "0", isUsed: false) } // MARK: - Sync func startSync() async throws -> Bool { let response = try await invoke(["op": "startSync"]) return (response["syncing"] as? Bool) ?? false } func stopSync() async { _ = try? await invoke(["op": "stopSync"]) } func syncStatus() async throws -> SyncStatus { let response = try await invoke(["op": "getSyncStatus"]) return SyncStatus(height: (response["height"] as? NSNumber)?.uint64Value ?? 0, daemonHeight: (response["daemonHeight"] as? NSNumber)?.uint64Value ?? 0, syncing: (response["syncing"] as? Bool) ?? false, connected: (response["connected"] as? Bool) ?? false) } // MARK: - Sends func send(address: String, amountXMR: String, priority: TransactionPriority) async throws -> (hash: String, fee: String) { let atomic = try MoneroAmount.atomic(fromXMR: amountXMR) let response = try await invoke(["op": "transferTo", "address": address.trimmingCharacters(in: .whitespacesAndNewlines), "amount": String(atomic), "priority": priority.rawValue]) return (response["txHash"] as? String ?? "", MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0")) } }