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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Exact decimal <-> drops conversion (1 XRP = 1_000_000 drops) without
|
||||
/// floating point rounding errors.
|
||||
enum XRPAmount {
|
||||
static let dropsPerXRP: UInt64 = 1_000_000
|
||||
static let maxDrops: UInt64 = 100_000_000_000 * dropsPerXRP // 1e11 XRP
|
||||
|
||||
static func drops(fromXRP value: String) throws -> UInt64 {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !text.hasPrefix("-"), !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") }
|
||||
|
||||
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
|
||||
guard let whole = UInt64(wholeText) else { throw XRPError.rpc("Enter a valid amount") }
|
||||
|
||||
var fraction: UInt64 = 0
|
||||
if parts.count == 2 {
|
||||
let fractionText = String(parts[1])
|
||||
guard fractionText.count <= 6, fractionText.allSatisfy({ $0.isNumber }) else {
|
||||
throw XRPError.rpc("XRP supports at most 6 decimal places")
|
||||
}
|
||||
let padded = fractionText.padding(toLength: 6, withPad: "0", startingAt: 0)
|
||||
guard let parsed = UInt64(padded) else { throw XRPError.rpc("Enter a valid amount") }
|
||||
fraction = parsed
|
||||
}
|
||||
|
||||
let (wholeDrops, overflow) = whole.multipliedReportingOverflow(by: dropsPerXRP)
|
||||
guard !overflow else { throw XRPError.rpc("Amount is too large") }
|
||||
let (total, carry) = wholeDrops.addingReportingOverflow(fraction)
|
||||
guard !carry, total <= maxDrops else { throw XRPError.rpc("Amount is too large") }
|
||||
return total
|
||||
}
|
||||
|
||||
static func xrp(fromDrops drops: String) -> String {
|
||||
guard let value = UInt64(drops.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
return "0"
|
||||
}
|
||||
return xrp(fromDrops: value)
|
||||
}
|
||||
|
||||
static func xrp(fromDrops value: UInt64) -> String {
|
||||
let whole = value / dropsPerXRP
|
||||
let fraction = value % dropsPerXRP
|
||||
if fraction == 0 { return String(whole) }
|
||||
var fractionText = String(format: "%06llu", fraction)
|
||||
while fractionText.hasSuffix("0") { fractionText.removeLast() }
|
||||
return "\(whole).\(fractionText)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
/// Canonical XRP Ledger binary serialization for the Payment transactions this
|
||||
/// wallet sends, plus the signing / transaction-id hashing around it.
|
||||
struct XRPPayment {
|
||||
var account: String
|
||||
var destination: String
|
||||
var amountDrops: UInt64
|
||||
var feeDrops: UInt64
|
||||
var sequence: UInt32
|
||||
var lastLedgerSequence: UInt32?
|
||||
var destinationTag: UInt32?
|
||||
var signingPublicKey: [UInt8]
|
||||
var signature: [UInt8]?
|
||||
|
||||
private static let typeUInt16: UInt8 = 1
|
||||
private static let typeUInt32: UInt8 = 2
|
||||
private static let typeAmount: UInt8 = 6
|
||||
private static let typeBlob: UInt8 = 7
|
||||
private static let typeAccountID: UInt8 = 8
|
||||
|
||||
func serialized(includingSignature: Bool) throws -> [UInt8] {
|
||||
var output: [UInt8] = []
|
||||
|
||||
// Fields must appear in (type, field) order.
|
||||
output += header(type: Self.typeUInt16, field: 2) // TransactionType
|
||||
output += uint16(0) // ttPAYMENT
|
||||
|
||||
output += header(type: Self.typeUInt32, field: 2) // Flags
|
||||
output += uint32(0)
|
||||
|
||||
output += header(type: Self.typeUInt32, field: 4) // Sequence
|
||||
output += uint32(sequence)
|
||||
|
||||
if let tag = destinationTag {
|
||||
output += header(type: Self.typeUInt32, field: 14) // DestinationTag
|
||||
output += uint32(tag)
|
||||
}
|
||||
|
||||
if let lastLedger = lastLedgerSequence {
|
||||
output += header(type: Self.typeUInt32, field: 27) // LastLedgerSequence
|
||||
output += uint32(lastLedger)
|
||||
}
|
||||
|
||||
output += header(type: Self.typeAmount, field: 1) // Amount
|
||||
output += try amount(amountDrops)
|
||||
|
||||
output += header(type: Self.typeAmount, field: 8) // Fee
|
||||
output += try amount(feeDrops)
|
||||
|
||||
output += header(type: Self.typeBlob, field: 3) // SigningPubKey
|
||||
output += variableLength(signingPublicKey)
|
||||
|
||||
if includingSignature, let signature {
|
||||
output += header(type: Self.typeBlob, field: 4) // TxnSignature
|
||||
output += variableLength(signature)
|
||||
}
|
||||
|
||||
output += header(type: Self.typeAccountID, field: 1) // Account
|
||||
output += variableLength(try XRPKey.accountId(fromAddress: account))
|
||||
|
||||
output += header(type: Self.typeAccountID, field: 3) // Destination
|
||||
output += variableLength(try XRPKey.accountId(fromAddress: destination))
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/// Bytes the private key signs (HashPrefix.transactionSig + tx).
|
||||
func signingPayload() throws -> [UInt8] {
|
||||
[0x53, 0x54, 0x58, 0x00] + (try serialized(includingSignature: false))
|
||||
}
|
||||
|
||||
/// The signed transaction blob submitted to the network (no hash prefix).
|
||||
func txBlob() throws -> [UInt8] {
|
||||
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
|
||||
return try serialized(includingSignature: true)
|
||||
}
|
||||
|
||||
func transactionHash() throws -> [UInt8] {
|
||||
guard signature != nil else { throw XRPError.rpc("Transaction is not signed") }
|
||||
let blob = try serialized(includingSignature: true)
|
||||
return XRPHash.sha512Half([0x54, 0x58, 0x4e, 0x00] + blob)
|
||||
}
|
||||
|
||||
// MARK: - Encoding helpers
|
||||
|
||||
private func header(type: UInt8, field: UInt8) -> [UInt8] {
|
||||
if field < 16 { return [(type << 4) | field] }
|
||||
return [type << 4, field]
|
||||
}
|
||||
|
||||
private func uint16(_ value: UInt16) -> [UInt8] {
|
||||
[UInt8(value >> 8), UInt8(value & 0xff)]
|
||||
}
|
||||
|
||||
private func uint32(_ value: UInt32) -> [UInt8] {
|
||||
[
|
||||
UInt8((value >> 24) & 0xff),
|
||||
UInt8((value >> 16) & 0xff),
|
||||
UInt8((value >> 8) & 0xff),
|
||||
UInt8(value & 0xff),
|
||||
]
|
||||
}
|
||||
|
||||
/// Native (XRP) amount: 8 bytes big-endian drops with the XRP flag bit set.
|
||||
private func amount(_ drops: UInt64) throws -> [UInt8] {
|
||||
guard drops <= XRPAmount.maxDrops else { throw XRPError.rpc("Amount is too large") }
|
||||
var bytes = uint64(drops)
|
||||
bytes[0] |= 0x40
|
||||
return bytes
|
||||
}
|
||||
|
||||
private func uint64(_ value: UInt64) -> [UInt8] {
|
||||
var bytes: [UInt8] = []
|
||||
for shift in stride(from: 56, through: 0, by: -8) {
|
||||
bytes.append(UInt8((value >> UInt64(shift)) & 0xff))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private func variableLength(_ bytes: [UInt8]) -> [UInt8] {
|
||||
var output: [UInt8] = []
|
||||
let count = bytes.count
|
||||
if count < 192 {
|
||||
output.append(UInt8(count))
|
||||
} else if count <= 12_480 {
|
||||
output.append(UInt8((count >> 8) + 192))
|
||||
output.append(UInt8(count & 0xff))
|
||||
} else {
|
||||
output.append(UInt8((count >> 16) + 240))
|
||||
output.append(UInt8((count >> 8) & 0xff))
|
||||
output.append(UInt8(count & 0xff))
|
||||
}
|
||||
return output + bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal XRP Ledger JSON-RPC client. A couple of public endpoints are tried
|
||||
/// in order so a single outage does not break the wallet.
|
||||
final class XRPClient {
|
||||
static let shared = XRPClient()
|
||||
|
||||
private let endpoints = [
|
||||
"https://s1.ripple.com:51234/",
|
||||
"https://s2.ripple.com:51234/",
|
||||
"https://xrplcluster.com/",
|
||||
]
|
||||
|
||||
private var preferredEndpoint = 0
|
||||
private let session: URLSession
|
||||
|
||||
private init() {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 20
|
||||
configuration.waitsForConnectivity = false
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
func rpc(_ method: String, _ params: [String: Any] = [:]) async throws -> [String: Any] {
|
||||
var lastError: Error = XRPError.network("XRP network is unavailable")
|
||||
for attempt in 0..<endpoints.count {
|
||||
let index = (preferredEndpoint + attempt) % endpoints.count
|
||||
guard let url = URL(string: endpoints[index]) else { continue }
|
||||
do {
|
||||
let result = try await send(method, params, to: url)
|
||||
preferredEndpoint = index
|
||||
return result
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private func send(_ method: String, _ params: [String: Any], to url: URL) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let body: [String: Any] = ["method": method, "params": [params], "id": 1]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||
throw XRPError.network("Ledger request failed (\(method))")
|
||||
}
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw XRPError.network("Malformed ledger response")
|
||||
}
|
||||
|
||||
let result = json["result"] as? [String: Any] ?? [:]
|
||||
if let error = result["error"] as? String {
|
||||
let message = result["error_message"] as? String ?? error
|
||||
throw XRPError.rpc(message)
|
||||
}
|
||||
if let error = json["error"] as? String {
|
||||
throw XRPError.rpc(json["error_message"] as? String ?? error)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func isAccountNotFound(_ error: Error) -> Bool {
|
||||
if case XRPError.rpc(let message) = error {
|
||||
return message == "actNotFound" || message.contains("Account not found")
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum XRPAlgorithm: String {
|
||||
case ed25519
|
||||
case secp256k1
|
||||
}
|
||||
|
||||
/// Set by the app target so this file stays compilable outside iOS.
|
||||
enum Secp256k1Backend {
|
||||
static var derivePrivateKey: ((Data) -> Data?)?
|
||||
static var publicKey: ((Data) -> Data?)?
|
||||
static var sign: ((Data, Data) -> Data?)?
|
||||
static var addScalar: ((Data, Data) -> Data?)?
|
||||
}
|
||||
|
||||
struct XRPKeypair {
|
||||
let algorithm: XRPAlgorithm
|
||||
/// ed25519: 32-byte seed. secp256k1: 32-byte scalar.
|
||||
let privateKey: [UInt8]
|
||||
/// ed25519: 0xED + 32-byte key. secp256k1: 33-byte compressed point.
|
||||
let publicKey: [UInt8]
|
||||
|
||||
var address: String { XRPKey.address(fromPublicKey: publicKey) }
|
||||
|
||||
var signingPublicKeyHex: String { Hex.encode(publicKey) }
|
||||
}
|
||||
|
||||
enum XRPKey {
|
||||
static let ed25519SeedVersion: [UInt8] = [0x01, 0xe1, 0x4b]
|
||||
static let familySeedVersion: [UInt8] = [0x21]
|
||||
static let accountIdVersion: [UInt8] = [0x00]
|
||||
static let ed25519Prefix: UInt8 = 0xED
|
||||
|
||||
// MARK: - Seeds
|
||||
|
||||
static func generateSeed(algorithm: XRPAlgorithm = .ed25519) -> String {
|
||||
var entropy = [UInt8](repeating: 0, count: 16)
|
||||
for index in entropy.indices { entropy[index] = UInt8.random(in: 0...255) }
|
||||
let version = algorithm == .ed25519 ? ed25519SeedVersion : familySeedVersion
|
||||
return XRPBase58.checkEncode(version: version, payload: entropy)
|
||||
}
|
||||
|
||||
static func decodeSeed(_ seed: String) throws -> (entropy: [UInt8], algorithm: XRPAlgorithm) {
|
||||
let decoded = try XRPBase58.decode(seed.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
guard decoded.count > 4 else { throw XRPError.invalidSeed }
|
||||
let body = Array(decoded[0..<(decoded.count - 4)])
|
||||
let checksum = Array(decoded[(decoded.count - 4)...])
|
||||
guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidSeed }
|
||||
|
||||
if body.count == ed25519SeedVersion.count + 16, Array(body.prefix(3)) == ed25519SeedVersion {
|
||||
return (Array(body.dropFirst(3)), .ed25519)
|
||||
}
|
||||
if body.count == familySeedVersion.count + 16, body[0] == familySeedVersion[0] {
|
||||
return (Array(body.dropFirst(1)), .secp256k1)
|
||||
}
|
||||
throw XRPError.unsupportedSeed
|
||||
}
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
/// Recovery phrase for a new wallet: 12 BIP-39 words.
|
||||
static func generateMnemonic() -> String {
|
||||
BIP39.generateMnemonic()
|
||||
}
|
||||
|
||||
/// Accepts either a 12 word BIP-39 recovery phrase or a legacy XRP family
|
||||
/// seed (`sEd…` / `s…`).
|
||||
static func keypair(fromSecret secret: String) throws -> XRPKeypair {
|
||||
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if BIP39.isMnemonic(trimmed) {
|
||||
return try keypair(fromMnemonic: trimmed)
|
||||
}
|
||||
return try keypair(fromSeed: trimmed)
|
||||
}
|
||||
|
||||
/// Derives the account at `m/44'/144'/0'/0/0`, the path hardware and mobile
|
||||
/// wallets use for XRP.
|
||||
static func keypair(fromMnemonic mnemonic: String) throws -> XRPKeypair {
|
||||
guard BIP39.entropy(from: mnemonic) != nil else {
|
||||
throw XRPError.invalidSeed
|
||||
}
|
||||
let seed = BIP39.seed(from: mnemonic)
|
||||
guard let master = BIP32.masterKey(fromSeed: seed),
|
||||
let derived = BIP32.derive(BIP32.xrpPath, from: master) else {
|
||||
throw XRPError.unsupportedSeed
|
||||
}
|
||||
return XRPKeypair(algorithm: .secp256k1,
|
||||
privateKey: derived.privateKey,
|
||||
publicKey: derived.publicKey)
|
||||
}
|
||||
|
||||
static func keypair(fromSeed seed: String) throws -> XRPKeypair {
|
||||
let (entropy, algorithm) = try decodeSeed(seed)
|
||||
switch algorithm {
|
||||
case .ed25519:
|
||||
let raw = XRPHash.sha512Half(entropy)
|
||||
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(raw))
|
||||
let publicKey = [ed25519Prefix] + Array(privateKey.publicKey.rawRepresentation)
|
||||
return XRPKeypair(algorithm: .ed25519, privateKey: raw, publicKey: publicKey)
|
||||
case .secp256k1:
|
||||
guard let derive = Secp256k1Backend.derivePrivateKey,
|
||||
let toPublic = Secp256k1Backend.publicKey,
|
||||
let privateKey = derive(Data(entropy)),
|
||||
let publicKey = toPublic(privateKey) else {
|
||||
throw XRPError.unsupportedSeed
|
||||
}
|
||||
return XRPKeypair(algorithm: .secp256k1,
|
||||
privateKey: Array(privateKey),
|
||||
publicKey: Array(publicKey))
|
||||
}
|
||||
}
|
||||
|
||||
static func address(fromPublicKey publicKey: [UInt8]) -> String {
|
||||
let accountId = XRPHash.ripemd160(XRPHash.sha256(publicKey))
|
||||
return XRPBase58.checkEncode(version: accountIdVersion, payload: accountId)
|
||||
}
|
||||
|
||||
static func isValidAddress(_ address: String) -> Bool {
|
||||
(try? XRPBase58.checkDecode(address, versionLength: 1)) != nil
|
||||
}
|
||||
|
||||
static func accountId(fromAddress address: String) throws -> [UInt8] {
|
||||
let payload = try XRPBase58.checkDecode(address, versionLength: 1)
|
||||
guard payload.count == 20 else { throw XRPError.invalidAddress }
|
||||
return payload
|
||||
}
|
||||
|
||||
// MARK: - Signing
|
||||
|
||||
/// ed25519 returns a 64-byte signature; secp256k1 a DER-encoded one, matching
|
||||
/// what the XRP Ledger expects for each signing scheme.
|
||||
static func sign(message: [UInt8], keypair: XRPKeypair) throws -> [UInt8] {
|
||||
switch keypair.algorithm {
|
||||
case .ed25519:
|
||||
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(keypair.privateKey))
|
||||
let signature = try privateKey.signature(for: Data(message))
|
||||
return Array(signature)
|
||||
case .secp256k1:
|
||||
let digest = XRPHash.sha512Half(message)
|
||||
guard let signFn = Secp256k1Backend.sign,
|
||||
let signature = signFn(Data(digest), Data(keypair.privateKey)) else {
|
||||
throw XRPError.unsupportedSeed
|
||||
}
|
||||
return Array(signature)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
/// High level XRP operations: wallet creation/import, balances, history, sends.
|
||||
final class XRPWalletService {
|
||||
static let shared = XRPWalletService()
|
||||
|
||||
private let rippleEpoch: TimeInterval = 946_684_800
|
||||
|
||||
// MARK: - Wallet lifecycle
|
||||
|
||||
func createWallet(name: String) throws -> (WalletRecord, String) {
|
||||
let mnemonic = XRPKey.generateMnemonic()
|
||||
let keypair = try XRPKey.keypair(fromMnemonic: mnemonic)
|
||||
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
|
||||
name: name,
|
||||
address: keypair.address,
|
||||
createdAt: Date())
|
||||
WalletStore.saveWallet(record, chain: .xrp)
|
||||
WalletStore.saveSecret(mnemonic, chain: .xrp)
|
||||
return (record, mnemonic)
|
||||
}
|
||||
|
||||
func importWallet(name: String, secret: String) throws -> WalletRecord {
|
||||
let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keypair = try XRPKey.keypair(fromSecret: trimmed)
|
||||
let record = WalletRecord(id: "xrp_\(Int(Date().timeIntervalSince1970))",
|
||||
name: name,
|
||||
address: keypair.address,
|
||||
createdAt: Date())
|
||||
WalletStore.saveWallet(record, chain: .xrp)
|
||||
WalletStore.saveSecret(trimmed, chain: .xrp)
|
||||
return record
|
||||
}
|
||||
|
||||
func address(forSecret secret: String) throws -> String {
|
||||
try XRPKey.keypair(fromSecret: secret.trimmingCharacters(in: .whitespacesAndNewlines)).address
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
func balance(address: String) async throws -> Balance {
|
||||
do {
|
||||
let result = try await XRPClient.shared.rpc("account_info",
|
||||
["account": address,
|
||||
"ledger_index": "current"])
|
||||
let accountData = result["account_data"] as? [String: Any] ?? [:]
|
||||
let drops = accountData["Balance"] as? String ?? "0"
|
||||
return Balance(total: XRPAmount.xrp(fromDrops: drops), unlocked: "", funded: true)
|
||||
} catch {
|
||||
if XRPClient.isAccountNotFound(error) {
|
||||
return Balance(total: "0", unlocked: "", funded: false)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func transactions(address: String) async throws -> [TransactionRecord] {
|
||||
let result = try await XRPClient.shared.rpc("account_tx",
|
||||
["account": address,
|
||||
"ledger_index_min": -1,
|
||||
"ledger_index_max": -1,
|
||||
"limit": 40])
|
||||
let entries = result["transactions"] as? [[String: Any]] ?? []
|
||||
var records: [TransactionRecord] = []
|
||||
|
||||
for entry in entries {
|
||||
guard let tx = entry["tx_json"] as? [String: Any] ?? entry["tx"] as? [String: Any] else { continue }
|
||||
guard (tx["TransactionType"] as? String) == "Payment" else { continue }
|
||||
let meta = entry["meta"] as? [String: Any] ?? entry["metaData"] as? [String: Any] ?? [:]
|
||||
if let txResult = meta["TransactionResult"] as? String, txResult != "tesSUCCESS" { continue }
|
||||
|
||||
// IOU / MPT deliveries carry an object; only native XRP is shown.
|
||||
let delivered = (meta["delivered_amount"] as? String)
|
||||
?? (meta["DeliveredAmount"] as? String)
|
||||
?? (tx["Amount"] as? String)
|
||||
guard let deliveredDrops = delivered else { continue }
|
||||
|
||||
let destination = tx["Destination"] as? String ?? ""
|
||||
let isIncoming = destination == address
|
||||
let date = (tx["date"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue + rippleEpoch) }
|
||||
|
||||
records.append(TransactionRecord(
|
||||
id: entry["hash"] as? String ?? tx["hash"] as? String ?? UUID().uuidString,
|
||||
amount: XRPAmount.xrp(fromDrops: deliveredDrops),
|
||||
timestamp: date,
|
||||
direction: isIncoming ? .incoming : .outgoing,
|
||||
confirmed: (entry["validated"] as? Bool) ?? false,
|
||||
fee: (tx["Fee"] as? String).map { XRPAmount.xrp(fromDrops: $0) },
|
||||
tag: (tx["DestinationTag"] as? NSNumber)?.intValue,
|
||||
counterparty: isIncoming ? (tx["Account"] as? String) : destination))
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// MARK: - Sends
|
||||
|
||||
func send(secret: String,
|
||||
to: String,
|
||||
amountXRP: String,
|
||||
destinationTag: UInt32?) async throws -> (hash: String, fee: String) {
|
||||
let destination = to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard XRPKey.isValidAddress(destination) else {
|
||||
throw XRPError.rpc("Enter a valid XRP address")
|
||||
}
|
||||
|
||||
let keypair = try XRPKey.keypair(fromSeed: secret)
|
||||
let amountDrops = try XRPAmount.drops(fromXRP: amountXRP)
|
||||
|
||||
async let infoCall = XRPClient.shared.rpc("account_info",
|
||||
["account": keypair.address,
|
||||
"ledger_index": "current"])
|
||||
async let feeCall = XRPClient.shared.rpc("fee", [:])
|
||||
async let ledgerCall = XRPClient.shared.rpc("ledger_current", [:])
|
||||
|
||||
let info = try await infoCall
|
||||
let accountData = info["account_data"] as? [String: Any] ?? [:]
|
||||
guard let sequence = (accountData["Sequence"] as? NSNumber)?.uint32Value else {
|
||||
throw XRPError.rpc("Could not read the account sequence")
|
||||
}
|
||||
let balanceDrops = UInt64((accountData["Balance"] as? String) ?? "0") ?? 0
|
||||
|
||||
let feeResult = try await feeCall
|
||||
let feeDrops = (feeResult["drops"] as? [String: Any])
|
||||
.flatMap { $0["open_ledger_fee"] as? String }
|
||||
.flatMap { UInt64($0) } ?? 12
|
||||
|
||||
let ledger = try await ledgerCall
|
||||
let ledgerIndex = (ledger["ledger_current_index"] as? NSNumber)?.uint32Value
|
||||
|
||||
guard amountDrops + feeDrops <= balanceDrops else {
|
||||
throw XRPError.rpc("Insufficient balance to cover the amount and network fee")
|
||||
}
|
||||
|
||||
var payment = XRPPayment(account: keypair.address,
|
||||
destination: destination,
|
||||
amountDrops: amountDrops,
|
||||
feeDrops: feeDrops,
|
||||
sequence: sequence,
|
||||
lastLedgerSequence: ledgerIndex.map { $0 + 20 },
|
||||
destinationTag: destinationTag,
|
||||
signingPublicKey: keypair.publicKey,
|
||||
signature: nil)
|
||||
|
||||
let signature = try XRPKey.sign(message: payment.signingPayload(), keypair: keypair)
|
||||
payment.signature = signature
|
||||
|
||||
let blob = Hex.encode(try payment.txBlob())
|
||||
let result = try await XRPClient.shared.rpc("submit", ["tx_blob": blob])
|
||||
let engineResult = result["engine_result"] as? String ?? ""
|
||||
guard engineResult.hasPrefix("tes") else {
|
||||
let message = result["engine_result_message"] as? String ?? "Transaction failed"
|
||||
throw XRPError.rpc("\(engineResult): \(message)")
|
||||
}
|
||||
|
||||
return (Hex.encode(try payment.transactionHash()), XRPAmount.xrp(fromDrops: feeDrops))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user