Replace the JavaScript mobile app with a native Swift iOS wallet
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user