spb/os-vault Public
Self-custody, multi-chain crypto wallet for macOS. One recovery phrase, six chain families, zero API keys — nothing leaves your Mac.
Swift 96%
Shell 3.4%
Makefile 0.6%
1//2// XRPLService.swift3// OS Vault4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import Foundation10import BigInt11import WalletCore1213/// XRP Ledger support: XRP + RLUSD (Ripple's stablecoin, an issued currency).14/// Signing via wallet-core (`OperationPayment` / `OperationTrustSet`),15/// networking via the genuinely free public JSON-RPC servers (xrplcluster.com16/// community full-history cluster; s.altnet.rippletest.net for testnet).17///18/// XRPL specifics surfaced to the user: the 1 XRP base reserve (+0.2 XRP per19/// trustline) is locked, not spendable; RLUSD requires a trustline — the app20/// offers one-tap creation and blocks sends to recipients without one.21public actor XRPLService {2223 public enum XRPLNetwork: String, CaseIterable, Codable, Sendable {24 case mainnet25 case testnet2627 public var apiURL: String {28 switch self {29 case .mainnet: return "https://xrplcluster.com"30 case .testnet: return "https://s.altnet.rippletest.net:51234"31 }32 }3334 var fallbackURL: String? {35 switch self {36 case .mainnet: return "https://s1.ripple.com:51234"37 case .testnet: return nil38 }39 }4041 /// RLUSD issuer (mainnet from Ripple docs; testnet issuer verified42 /// live via account_info — tryrlusd.com faucet).43 public var rlusdIssuer: String {44 switch self {45 case .mainnet: return "rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De"46 case .testnet: return "rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV"47 }48 }4950 public func explorerTxURL(_ hash: String) -> URL {51 switch self {52 case .mainnet: return URL(string: "https://livenet.xrpl.org/transactions/\(hash)")!53 case .testnet: return URL(string: "https://testnet.xrpl.org/transactions/\(hash)")!54 }55 }5657 public func explorerAddressURL(_ address: String) -> URL {58 switch self {59 case .mainnet: return URL(string: "https://livenet.xrpl.org/accounts/\(address)")!60 case .testnet: return URL(string: "https://testnet.xrpl.org/accounts/\(address)")!61 }62 }6364 public var displayName: String {65 switch self {66 case .mainnet: return "XRP Ledger"67 case .testnet: return "XRPL Testnet"68 }69 }7071 public var isTestnet: Bool { self == .testnet }72 }7374 public static let networkKey = "osvault.xrpl.network"75 /// 160-bit hex currency code for "RLUSD" (non-standard >3-char code).76 public static let rlusdCurrencyHex = "524C555344000000000000000000000000000000"77 public static let xrpDecimals = 6 // 1 XRP = 1_000_000 drops78 static let baseReserveDrops: UInt64 = 1_000_00079 static let ownerReserveDrops: UInt64 = 200_0008081 public struct XRPLBalances: Sendable {82 public var drops: UInt64 = 083 public var reserveDrops: UInt64 = 084 public var rlusdValue: String = "0" // issued-currency decimal string85 public var hasRLUSDTrustline = false86 public var accountExists = false87 public var spendableDrops: UInt64 {88 drops > reserveDrops ? drops - reserveDrops : 089 }90 }9192 public struct PreparedXRPLSend: Sendable {93 public let recipient: String94 /// Drops for XRP; decimal string for RLUSD.95 public let amountDrops: UInt6496 public let amountValue: String97 public let isRLUSD: Bool98 public let feeDrops: UInt6499 public let activatesRecipient: Bool100 public let network: XRPLNetwork101 }102103 private var address: String?104 private var network: XRPLNetwork = .testnet105106 // MARK: - Setup107108 public func configure(mnemonic: String) throws {109 let stored = UserDefaults.standard.string(forKey: Self.networkKey)110 network = stored.flatMap(XRPLNetwork.init(rawValue:)) ?? .testnet111 guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {112 throw WalletError.invalidMnemonic113 }114 address = wallet.getAddressForCoin(coin: .xrp)115 }116117 public var isConfigured: Bool { address != nil }118 public var currentNetwork: XRPLNetwork { network }119 public var publicAddress: String? { address }120121 public func switchNetwork(to newNetwork: XRPLNetwork) {122 UserDefaults.standard.set(newNetwork.rawValue, forKey: Self.networkKey)123 network = newNetwork124 }125126 public static func validate(address: String) -> Bool {127 AnyAddress.isValid(string: address, coin: .xrp)128 }129130 // MARK: - JSON-RPC131132 private func rpc(_ method: String, _ params: [String: Any]) async throws -> [String: Any] {133 let body: [String: Any] = ["method": method, "params": [params]]134 var lastError: Error = WalletError.rpc("XRPL endpoint unreachable.")135 for url in [network.apiURL, network.fallbackURL].compactMap({ $0 }) {136 var request = URLRequest(url: URL(string: url)!)137 request.httpMethod = "POST"138 request.setValue("application/json", forHTTPHeaderField: "Content-Type")139 request.httpBody = try JSONSerialization.data(withJSONObject: body)140 request.timeoutInterval = 20141 do {142 let (data, _) = try await URLSession.shared.data(for: request)143 guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],144 let result = json["result"] as? [String: Any] else {145 lastError = WalletError.rpc("Malformed XRPL response.")146 continue147 }148 return result149 } catch {150 lastError = WalletError.rpc(error.localizedDescription)151 }152 }153 throw lastError154 }155156 // MARK: - Balances157158 public func fetchBalances() async throws -> XRPLBalances {159 guard let address else { throw WalletError.internalError("XRPL not configured.") }160 var balances = XRPLBalances()161 let info = try await rpc("account_info", ["account": address, "ledger_index": "validated"])162 if info["error"] as? String == "actNotFound" {163 return balances // unfunded: first deposit must be ≥ 1 XRP164 }165 guard let accountData = info["account_data"] as? [String: Any] else {166 throw WalletError.rpc((info["error_message"] as? String) ?? "account_info failed.")167 }168 balances.accountExists = true169 balances.drops = (accountData["Balance"] as? String).flatMap(UInt64.init) ?? 0170 let ownerCount = (accountData["OwnerCount"] as? NSNumber)?.uint64Value ?? 0171 balances.reserveDrops = Self.baseReserveDrops + ownerCount * Self.ownerReserveDrops172173 let lines = try await rpc("account_lines", ["account": address, "ledger_index": "validated"])174 for line in (lines["lines"] as? [[String: Any]]) ?? [] {175 if line["currency"] as? String == Self.rlusdCurrencyHex,176 line["account"] as? String == network.rlusdIssuer {177 balances.hasRLUSDTrustline = true178 balances.rlusdValue = (line["balance"] as? String) ?? "0"179 }180 }181 return balances182 }183184 private func recipientHasRLUSDTrustline(_ recipient: String) async throws -> Bool {185 if recipient == network.rlusdIssuer { return true }186 let lines = try await rpc("account_lines", ["account": recipient, "ledger_index": "validated"])187 if lines["error"] as? String == "actNotFound" { return false }188 for line in (lines["lines"] as? [[String: Any]]) ?? [] {189 if line["currency"] as? String == Self.rlusdCurrencyHex,190 line["account"] as? String == network.rlusdIssuer {191 return true192 }193 }194 return false195 }196197 // MARK: - Estimate198199 public func estimateSend(to recipient: String, amountDrops: UInt64,200 amountValue: String, isRLUSD: Bool) async throws -> PreparedXRPLSend {201 guard Self.validate(address: recipient) else { throw WalletError.invalidAddress }202 let feeResult = try await rpc("fee", [:])203 let openFee = ((feeResult["drops"] as? [String: Any])?["open_ledger_fee"] as? String)204 .flatMap(UInt64.init) ?? 10205 let fee = max(10, min(openFee, 10_000)) // sane bounds206207 var activates = false208 if isRLUSD {209 guard try await recipientHasRLUSDTrustline(recipient) else {210 throw WalletError.internalError("The recipient has no RLUSD trustline — they must add one before they can receive RLUSD.")211 }212 } else {213 let info = try await rpc("account_info", ["account": recipient, "ledger_index": "validated"])214 if info["error"] as? String == "actNotFound" {215 activates = true216 guard amountDrops >= Self.baseReserveDrops else {217 throw WalletError.internalError("This account doesn't exist yet — the first payment must be at least 1 XRP (base reserve).")218 }219 }220 }221 return PreparedXRPLSend(222 recipient: recipient, amountDrops: amountDrops, amountValue: amountValue,223 isRLUSD: isRLUSD, feeDrops: fee, activatesRecipient: activates, network: network224 )225 }226227 // MARK: - Sign + submit228229 private func signingContext() async throws -> (sequence: UInt32, lastLedger: UInt32) {230 guard let address else { throw WalletError.internalError("XRPL not configured.") }231 let info = try await rpc("account_info", ["account": address, "ledger_index": "validated"])232 guard let accountData = info["account_data"] as? [String: Any],233 let sequence = (accountData["Sequence"] as? NSNumber)?.uint32Value,234 let ledgerIndex = (info["ledger_index"] as? NSNumber)?.uint32Value else {235 throw WalletError.rpc("Could not read the account sequence.")236 }237 return (sequence, ledgerIndex + 30)238 }239240 private func submit(_ input: RippleSigningInput) async throws -> String {241 let output: RippleSigningOutput = AnySigner.sign(input: input, coin: .xrp)242 guard output.error == .ok, !output.encoded.isEmpty else {243 throw WalletError.signingFailed244 }245 let blob = output.encoded.map { String(format: "%02X", $0) }.joined()246 let result = try await rpc("submit", ["tx_blob": blob])247 let engine = (result["engine_result"] as? String) ?? "unknown"248 guard engine == "tesSUCCESS" || engine.hasPrefix("terQUEUED") else {249 throw WalletError.rpc((result["engine_result_message"] as? String) ?? engine)250 }251 return ((result["tx_json"] as? [String: Any])?["hash"] as? String) ?? blob.prefix(64).lowercased()252 }253254 public func send(_ prepared: PreparedXRPLSend, mnemonic: String) async throws -> String {255 guard let address else { throw WalletError.internalError("XRPL not configured.") }256 guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {257 throw WalletError.invalidMnemonic258 }259 let context = try await signingContext()260261 var payment = RippleOperationPayment()262 payment.destination = prepared.recipient263 if prepared.isRLUSD {264 var currency = RippleCurrencyAmount()265 currency.currency = Self.rlusdCurrencyHex266 currency.value = prepared.amountValue267 currency.issuer = prepared.network.rlusdIssuer268 payment.currencyAmount = currency269 } else {270 payment.amount = Int64(prepared.amountDrops)271 }272273 var input = RippleSigningInput()274 input.account = address275 input.fee = Int64(prepared.feeDrops)276 input.sequence = context.sequence277 input.lastLedgerSequence = context.lastLedger278 input.privateKey = wallet.getKeyForCoin(coin: .xrp).data279 input.operationOneof = .opPayment(payment)280 return try await submit(input)281 }282283 /// One-tap RLUSD trustline (costs the 12-drop fee + locks 0.2 XRP reserve).284 public func createRLUSDTrustline(mnemonic: String) async throws -> String {285 guard let address else { throw WalletError.internalError("XRPL not configured.") }286 guard let wallet = HDWallet(mnemonic: mnemonic, passphrase: "") else {287 throw WalletError.invalidMnemonic288 }289 let context = try await signingContext()290291 var limit = RippleCurrencyAmount()292 limit.currency = Self.rlusdCurrencyHex293 limit.value = "1000000000"294 limit.issuer = network.rlusdIssuer295 var trustSet = RippleOperationTrustSet()296 trustSet.limitAmount = limit297298 var input = RippleSigningInput()299 input.account = address300 input.fee = 12301 input.sequence = context.sequence302 input.lastLedgerSequence = context.lastLedger303 input.flags = 131_072 // tfSetNoRipple304 input.privateKey = wallet.getKeyForCoin(coin: .xrp).data305 input.operationOneof = .opTrustSet(trustSet)306 return try await submit(input)307 }308309 // MARK: - Formatting310311 public static func formatXRP(_ drops: UInt64) -> String {312 TokenAmount.format(BigUInt(drops), decimals: xrpDecimals)313 }314315 public static func parseXRP(_ input: String) -> UInt64? {316 guard let units = TokenAmount.parse(input, decimals: xrpDecimals), units <= BigUInt(UInt64.max) else { return nil }317 return UInt64(units)318 }319320 /// RLUSD values travel as decimal strings on-ledger; validate shape only.321 public static func validRLUSDAmount(_ input: String) -> String? {322 let normalized = input.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")323 guard TokenAmount.parse(normalized, decimals: 15) ?? 0 > 0 else { return nil }324 return normalized325 }326}327