Replace the JavaScript mobile app with a native Swift iOS wallet

This commit is contained in:
2026-09-19 13:33:11 +08:00
parent d80b58b704
commit 3e1a39d4d7
79 changed files with 25482 additions and 4023 deletions
+224
View File
@@ -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"))
}
}