224 lines
9.1 KiB
Swift
224 lines
9.1 KiB
Swift
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"])
|
|
}
|
|
|
|
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: nil)
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
}
|
|
} |