SPB Git

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%
13.6 KB · 327 lines swift
Raw Blame History
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