// // XRPLService.swift // OS Vault // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import Foundation import BigInt import WalletCore /// XRP Ledger support: XRP + RLUSD (Ripple's stablecoin, an issued currency). /// Signing via wallet-core (`OperationPayment` / `OperationTrustSet`), /// networking via the genuinely free public JSON-RPC servers (xrplcluster.com /// community full-history cluster; s.altnet.rippletest.net for testnet). /// /// XRPL specifics surfaced to the user: the 1 XRP base reserve (+0.2 XRP per /// trustline) is locked, not spendable; RLUSD requires a trustline — the app /// offers one-tap creation and blocks sends to recipients without one. public actor XRPLService { public enum XRPLNetwork: String, CaseIterable, Codable, Sendable { case mainnet case testnet public var apiURL: String { switch self { case .mainnet: return "https://xrplcluster.com" case .testnet: return "https://s.altnet.rippletest.net:51234" } } var fallbackURL: String? { switch self { case .mainnet: return "https://s1.ripple.com:51234" case .testnet: return nil } } /// RLUSD issuer (mainnet from Ripple docs; testnet issuer verified /// live via account_info — tryrlusd.com faucet). public var rlusdIssuer: String { switch self { case .mainnet: return "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De" case .testnet: return "rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV" } } public func explorerTxURL(_ hash: String) -> URL { switch self { case .mainnet: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)")! case .testnet: return URL(string: "https://testnet.xrpl.org/transactions/\(hash)")! } } public func explorerAddressURL(_ address: String) -> URL { switch self { case .mainnet: return URL(string: "https://livenet.xrpl.org/accounts/\(address)")! case .testnet: return URL(string: "https://testnet.xrpl.org/accounts/\(address)")! } } public var displayName: String { switch self { case .mainnet: return "XRP Ledger" case .testnet: return "XRPL Testnet" } } public var isTestnet: Bool { self == .testnet } } public static let networkKey = "osvault.xrpl.network" /// 160-bit hex currency code for "RLUSD" (non-standard >3-char code). public static let rlusdCurrencyHex = "524C555344000000000000000000000000000000" public static let xrpDecimals = 6 // 1 XRP = 1_000_000 drops static let baseReserveDrops: UInt64 = 1_000_000 static let ownerReserveDrops: UInt64 = 200_000 public struct XRPLBalances: Sendable { public var drops: UInt64 = 0 public var reserveDrops: UInt64 = 0 public var rlusdValue: String = "0" // issued-currency decimal string public var hasRLUSDTrustline = false public var accountExists = false public var spendableDrops: UInt64 { drops > reserveDrops ? drops - reserveDrops : 0 } } public struct PreparedXRPLSend: Sendable { public let recipient: String /// Drops for XRP; decimal string for RLUSD. public let amountDrops: UInt64 public let amountValue: String public let isRLUSD: Bool public let feeDrops: UInt64 public let activatesRecipient: Bool public let network: XRPLNetwork } private var address: String? private var network: XRPLNetwork = .testnet // MARK: - Setup public func configure(mnemonic: String) throws { let stored = UserDefaults.standard.string(forKey: Self.networkKey) network = stored.flatMap(XRPLNetwork.init(rawValue:)) ?? .testnet guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } address = wallet.getAddressForCoin(coin: .xrp) } public var isConfigured: Bool { address != nil } public var currentNetwork: XRPLNetwork { network } public var publicAddress: String? { address } public func switchNetwork(to newNetwork: XRPLNetwork) { UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey) network = newNetwork } public static func validate(address: String) -> Bool { AnyAddress.isValid(string: address, coin: .xrp) } // MARK: - JSON-RPC private func rpc(_ method: String, _ params: [String: Any]) async throws -> [String: Any] { let body: [String: Any] = ["method": method, "params": [params]] var lastError: Error = WalletError.rpc("XRPL endpoint unreachable.") for url in [network.apiURL, network.fallbackURL].compactMap({ $0 }) { var request = URLRequest(url: URL(string: url)!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) request.timeoutInterval = 20 do { let (data, _) = try await URLSession.shared.data(for: request) guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let result = json["result"] as? [String: Any] else { lastError = WalletError.rpc("Malformed XRPL response.") continue } return result } catch { lastError = WalletError.rpc(error.localizedDescription) } } throw lastError } // MARK: - Balances public func fetchBalances() async throws -> XRPLBalances { guard let address else { throw WalletError.internalError("XRPL not configured.") } var balances = XRPLBalances() let info = try await rpc("account_info", ["account": address, "ledger_index": "validated"]) if info["error"] as? String == "actNotFound" { return balances // unfunded: first deposit must be ≥ 1 XRP } guard let accountData = info["account_data"] as? [String: Any] else { throw WalletError.rpc((info["error_message"] as? String) ?? "account_info failed.") } balances.accountExists = true balances.drops = (accountData["Balance"] as? String).flatMap(UInt64.init) ?? 0 let ownerCount = (accountData["OwnerCount"] as? NSNumber)?.uint64Value ?? 0 balances.reserveDrops = Self.baseReserveDrops + ownerCount * Self.ownerReserveDrops let lines = try await rpc("account_lines", ["account": address, "ledger_index": "validated"]) for line in (lines["lines"] as? [[String: Any]]) ?? [] { if line["currency"] as? String == Self.rlusdCurrencyHex, line["account"] as? String == network.rlusdIssuer { balances.hasRLUSDTrustline = true balances.rlusdValue = (line["balance"] as? String) ?? "0" } } return balances } private func recipientHasRLUSDTrustline(_ recipient: String) async throws -> Bool { if recipient == network.rlusdIssuer { return true } let lines = try await rpc("account_lines", ["account": recipient, "ledger_index": "validated"]) if lines["error"] as? String == "actNotFound" { return false } for line in (lines["lines"] as? [[String: Any]]) ?? [] { if line["currency"] as? String == Self.rlusdCurrencyHex, line["account"] as? String == network.rlusdIssuer { return true } } return false } // MARK: - Estimate public func estimateSend(to recipient: String, amountDrops: UInt64, amountValue: String, isRLUSD: Bool) async throws -> PreparedXRPLSend { guard Self.validate(address: recipient) else { throw WalletError.invalidAddress } let feeResult = try await rpc("fee", [:]) let openFee = ((feeResult["drops"] as? [String: Any])?["open_ledger_fee"] as? String) .flatMap(UInt64.init) ?? 10 let fee = max(10, min(openFee, 10_000)) // sane bounds var activates = false if isRLUSD { guard try await recipientHasRLUSDTrustline(recipient) else { throw WalletError.internalError("The recipient has no RLUSD trustline — they must add one before they can receive RLUSD.") } } else { let info = try await rpc("account_info", ["account": recipient, "ledger_index": "validated"]) if info["error"] as? String == "actNotFound" { activates = true guard amountDrops >= Self.baseReserveDrops else { throw WalletError.internalError("This account doesn't exist yet — the first payment must be at least 1 XRP (base reserve).") } } } return PreparedXRPLSend( recipient: recipient, amountDrops: amountDrops, amountValue: amountValue, isRLUSD: isRLUSD, feeDrops: fee, activatesRecipient: activates, network: network ) } // MARK: - Sign + submit private func signingContext() async throws -> (sequence: UInt32, lastLedger: UInt32) { guard let address else { throw WalletError.internalError("XRPL not configured.") } let info = try await rpc("account_info", ["account": address, "ledger_index": "validated"]) guard let accountData = info["account_data"] as? [String: Any], let sequence = (accountData["Sequence"] as? NSNumber)?.uint32Value, let ledgerIndex = (info["ledger_index"] as? NSNumber)?.uint32Value else { throw WalletError.rpc("Could not read the account sequence.") } return (sequence, ledgerIndex + 30) } private func submit(_ input: RippleSigningInput) async throws -> String { let output: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp) guard output.error == .ok, !output.encoded.isEmpty else { throw WalletError.signingFailed } let blob = output.encoded.map { String(format: "%02X", $0) }.joined() let result = try await rpc("submit", ["tx_blob": blob]) let engine = (result["engine_result"] as? String) ?? "unknown" guard engine == "tesSUCCESS" || engine.hasPrefix("terQUEUED") else { throw WalletError.rpc((result["engine_result_message"] as? String) ?? engine) } return ((result["tx_json"] as? [String: Any])?["hash"] as? String) ?? blob.prefix(64).lowercased() } public func send(_ prepared: PreparedXRPLSend, mnemonic: String) async throws -> String { guard let address else { throw WalletError.internalError("XRPL not configured.") } guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } let context = try await signingContext() var payment = RippleOperationPayment() payment.destination = prepared.recipient if prepared.isRLUSD { var currency = RippleCurrencyAmount() currency.currency = Self.rlusdCurrencyHex currency.value = prepared.amountValue currency.issuer = prepared.network.rlusdIssuer payment.currencyAmount = currency } else { payment.amount = Int64(prepared.amountDrops) } var input = RippleSigningInput() input.account = address input.fee = Int64(prepared.feeDrops) input.sequence = context.sequence input.lastLedgerSequence = context.lastLedger input.privateKey = wallet.getKeyForCoin(coin: .xrp).data input.operationOneof = .opPayment(payment) return try await submit(input) } /// One-tap RLUSD trustline (costs the 12-drop fee + locks 0.2 XRP reserve). public func createRLUSDTrustline(mnemonic: String) async throws -> String { guard let address else { throw WalletError.internalError("XRPL not configured.") } guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else { throw WalletError.invalidMnemonic } let context = try await signingContext() var limit = RippleCurrencyAmount() limit.currency = Self.rlusdCurrencyHex limit.value = "1000000000" limit.issuer = network.rlusdIssuer var trustSet = RippleOperationTrustSet() trustSet.limitAmount = limit var input = RippleSigningInput() input.account = address input.fee = 12 input.sequence = context.sequence input.lastLedgerSequence = context.lastLedger input.flags = 131_072 // tfSetNoRipple input.privateKey = wallet.getKeyForCoin(coin: .xrp).data input.operationOneof = .opTrustSet(trustSet) return try await submit(input) } // MARK: - Formatting public static func formatXRP(_ drops: UInt64) -> String { TokenAmount.format(BigUInt(drops), decimals: xrpDecimals) } public static func parseXRP(_ input: String) -> UInt64? { guard let units = TokenAmount.parse(input, decimals: xrpDecimals), units <= BigUInt(UInt64.max) else { return nil } return UInt64(units) } /// RLUSD values travel as decimal strings on-ledger; validate shape only. public static func validRLUSDAmount(_ input: String) -> String? { let normalized = input.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".") guard TokenAmount.parse(normalized, decimals: 15) ?? 0 > 0 else { return nil } return normalized } }