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, height: (entry["ledger_index"] as? NSNumber)?.uint64Value, ledgerIndex: (entry["ledger_index"] as? NSNumber)?.uint64Value, sequence: (tx["Sequence"] as? NSNumber)?.uint64Value)) } // Newest first, matching the Monero list. return records.sorted { ($0.timestamp ?? .distantFuture) > ($1.timestamp ?? .distantFuture) } } // 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)) } }