Files

327 lines
14 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"])
}
/// 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 ?? ""
}
/// Block time for a height. wallet2 keeps no timestamp for transfers, so the
/// only way to date a Monero transaction is to ask the daemon for the block
/// it landed in.
func blockTimestamp(height: UInt64) async throws -> Date? {
guard let url = URL(string: "http://\(Self.daemonHost):18081/json_rpc") else { return nil }
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_block_header_by_height",
"params": ["height": height],
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let result = json["result"] as? [String: Any],
let header = result["block_header"] as? [String: Any],
let timestamp = (header["timestamp"] as? NSNumber)?.doubleValue, timestamp > 0 else {
return nil
}
return Date(timeIntervalSince1970: timestamp)
}
// 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)
}
.sorted { newestFirst($0, $1) }
}
/// Newest first. wallet2 gives transfers no timestamp, but the block height is
/// chronological and always present, so that is the ordering key; anything
/// still in the pool (height 0) belongs at the top.
private func newestFirst(_ left: TransactionRecord, _ right: TransactionRecord) -> Bool {
func key(_ record: TransactionRecord) -> UInt64 {
guard let height = record.height, height > 0 else { return .max }
return height
}
let leftKey = key(left)
let rightKey = key(right)
if leftKey != rightKey { return leftKey > rightKey }
return (left.timestamp ?? .distantFuture) > (right.timestamp ?? .distantFuture)
}
// 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,
accountIndex: Int = 0) 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,
"accountIndex": accountIndex])
return (response["txHash"] as? String ?? "",
MoneroAmount.xmr(fromAtomic: response["fee"] as? String ?? "0"))
}
}