import Foundation import os /// On-device verification against vectors generated from the reference /// implementation (xrpl.js + ripple-keypairs + ripple-binary-codec). Runs only /// when the app is launched with `-koinsSelfTest`. enum SelfTest { private static let log = OSLog(subsystem: "com.koins.app", category: "selftest") /// Writes to both stdout and the unified log so results are visible however /// the app was launched. private static var report: [String] = [] private static func emit(_ line: String) { print(line) os_log("%{public}@", log: log, type: .default, line) report.append(line) } /// Writes the collected lines into the app container so the result can be /// read back off the device (console output is not always reachable). private static func flushReport() { guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } let url = documents.appendingPathComponent("koins-selftest.txt") try? report.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) } static func runIfRequested() { if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") { emit("KOINS_SELFTEST begin") run() emit("KOINS_SELFTEST end") } if ProcessInfo.processInfo.arguments.contains("-koinsSelfTestNetwork") { runNetwork() } } /// End-to-end check of the live network paths (XRP RPC + native Monero /// wallet). Triggered with `-koinsSelfTestNetwork`; cleans up after itself. static func runNetwork() { // Detached so the test does not inherit the main actor: the UI thread must // stay free, otherwise the launch-time call would deadlock. Task.detached(priority: .userInitiated) { emit("KOINS_NET begin") // The test creates temporary wallets, so snapshot the real ones and put // them back afterwards instead of wiping them. let backup = (xrp: WalletStore.wallet(.xrp), xmr: WalletStore.wallet(.xmr), xrpSecret: WalletStore.secret(.xrp), xmrSecret: WalletStore.secret(.xmr)) func restoreWallets() { if let xrp = backup.xrp { WalletStore.saveWallet(xrp, chain: .xrp) } else { WalletStore.removeWallet(.xrp) } if let secret = backup.xrpSecret { WalletStore.saveSecret(secret, chain: .xrp) } if let xmr = backup.xmr { WalletStore.saveWallet(xmr, chain: .xmr) } else { WalletStore.removeWallet(.xmr) } if let secret = backup.xmrSecret { WalletStore.saveSecret(secret, chain: .xmr) } emit("KOINS_NET restored saved wallets") } do { let (record, phrase) = try XRPWalletService.shared.createWallet(name: "koins-net-selftest") emit("KOINS_NET xrp address \(record.address) phraseWords \(phrase.split(separator: " ").count)") let balance = try await XRPWalletService.shared.balance(address: record.address) emit("KOINS_NET xrp funded=\(balance.funded) balance=\(balance.total)") let history = try await XRPWalletService.shared.transactions(address: record.address) emit("KOINS_NET xrp transactions=\(history.count)") // A known funded account, to exercise the funded-balance path. for reference in ["rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C"] { let reference = try await XRPWalletService.shared.balance(address: reference) emit("KOINS_NET xrp reference funded=\(reference.funded) balance=\(reference.total)") let referenceHistory = try await XRPWalletService.shared.transactions(address: "rHR6xtDW7BxeQN8VB8R9HzXzyMeS42n7C") emit("KOINS_NET xrp reference transactions=\(referenceHistory.count)") } } catch { emit("KOINS_NET xrp FAIL \(error)") } do { let created = try await MoneroService.shared.createWallet(name: "koins-net-selftest") let words = created.mnemonic.split(separator: " ").count emit("KOINS_NET xmr address \(created.address) mnemonicWords \(words)") let started = try await MoneroService.shared.startSync() emit("KOINS_NET xmr startedSync \(started)") for attempt in 1...6 { try? await Task.sleep(nanoseconds: 5_000_000_000) let status = try await MoneroService.shared.syncStatus() emit("KOINS_NET xmr status \(attempt) height=\(status.height) daemon=\(status.daemonHeight) connected=\(status.connected) syncing=\(status.syncing)") } let balance = try await MoneroService.shared.balance() emit("KOINS_NET xmr balance \(balance.total) unlocked \(balance.unlocked)") let accounts = try await MoneroService.shared.accounts() emit("KOINS_NET xmr accounts \(accounts.count) subaddresses \(accounts.first?.subaddresses.count ?? 0)") let account = try await MoneroService.shared.createAccount(label: "Savings") emit("KOINS_NET xmr new account index \(account.index) address \(account.address.prefix(12))") let subaddress = try await MoneroService.shared.createSubaddress(accountIndex: 0, label: "From Alice") emit("KOINS_NET xmr new subaddress index \(subaddress.index) address \(subaddress.address.prefix(12))") let reloaded = try await MoneroService.shared.accounts() emit("KOINS_NET xmr accounts after \(reloaded.count) subaddresses \(reloaded.first?.subaddresses.count ?? 0)") await MoneroService.shared.stopSync() await MoneroService.shared.removeWallet(name: "koins-net-selftest") } catch { emit("KOINS_NET xmr FAIL \(error)") } restoreWallets() emit("KOINS_NET end") flushReport() } } static func run() { var failures = 0 func expect(_ name: String, _ actual: String, _ wanted: String) { let ok = actual.lowercased() == wanted.lowercased() if !ok { failures += 1 } emit("KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)") } expect("ripemd160", Hex.encode(RIPEMD160.hash(Array("abc".utf8))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc") // ed25519 vector let edSeed = "sEdSPx8CLbFUna3DJdvJ74aQPLdemqu" let edAddress = "rEhh6f9rj5UUBhFzGGaxS5zYU2CCqKFXBC" let edPublicKey = "EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F1734" if let keypair = try? XRPKey.keypair(fromSeed: edSeed) { expect("ed25519 pubkey", Hex.encode(keypair.publicKey), edPublicKey) expect("ed25519 address", keypair.address, edAddress) } else { failures += 1 emit("KOINS_SELFTEST ed25519: FAIL keypair threw") } // secp256k1 vector: exercises the OpenSSL-backed derivation on device. let secpSeed = "spFbtYoPd7Hg7AD9XZn7CBwUKC5Qe" let secpPublicKey = "0322DC9BB14903F71186BC46B0107D55A069D23CD97BBD205491A992EC68FA0ABE" let secpAddress = "rpsRYc8DbXzfVN32w3hZjUtyuF1K89hu47" do { let keypair = try XRPKey.keypair(fromSeed: secpSeed) expect("secp256k1 algorithm", keypair.algorithm.rawValue, "secp256k1") expect("secp256k1 pubkey", Hex.encode(keypair.publicKey), secpPublicKey) expect("secp256k1 address", keypair.address, secpAddress) } catch { failures += 1 emit("KOINS_SELFTEST secp256k1: FAIL \(error)") } // Serialization + hashing reference vector. let signingHex = "53545800120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F173481149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1" let referenceSignature = "F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D" let expectedBlob = "120000220000000024000030392E00000309201B05E69F3B61400000000016E36068400000000000000C7321EDF9096653463E3A0B79A67E69D9E66AB83D93D4E867335ADE5DAFE8467F4F17347440F560672EA3103D2E43179302CD66ACF563A4EDFBA90ADE24E378B743135FD989EE132FA4B703021C2DD1979E770A6CEBC99556EA330AF9E6E7186673F837CB0D81149A26172134BFDA9708A73305AF2776A26DB7B4F5831492891F44F454D156327AA24F4C8BB72540E31BF1" let expectedHash = "F853B5391CD6317EA370830EEA3C047AC8D21E9FA00E4116D94C48E360B0434F" var payment = XRPPayment(account: edAddress, destination: "rNMovRR3WPbFLVaSbETCCR71XsqyxhJ9P6", amountDrops: 1_500_000, feeDrops: 12, sequence: 12345, lastLedgerSequence: 99000123, destinationTag: 777, signingPublicKey: Hex.decode(edPublicKey) ?? [], signature: Hex.decode(referenceSignature)) if let payload = try? payment.signingPayload() { expect("signing payload", Hex.encode(payload), signingHex) } else { failures += 1 } if let blob = try? payment.txBlob() { expect("tx blob", Hex.encode(blob), expectedBlob) if let hash = try? payment.transactionHash() { expect("tx hash", Hex.encode(hash), expectedHash) } else { failures += 1 } } else { failures += 1 } // BIP-39 / BIP-32 (m/44'/144'/0'/0/0): the recovery phrase used for new XRP // wallets. Vectors come from @scure/bip39 + @scure/bip32 + ripple-keypairs. let zeroMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" expect("bip39 encode", BIP39.mnemonic(from: [UInt8](repeating: 0, count: 16)), zeroMnemonic) expect("bip39 entropy", Hex.encode(BIP39.entropy(from: zeroMnemonic) ?? []), String(repeating: "00", count: 16)) expect("bip39 rejects bad checksum", BIP39.entropy(from: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon") == nil ? "rejected" : "accepted", "rejected") // BIP-32 intermediate values, to isolate derivation problems. if let master = BIP32.masterKey(fromSeed: BIP39.seed(from: zeroMnemonic)) { expect("bip32 master key", Hex.encode(master.privateKey), "1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67") expect("bip32 master chain code", Hex.encode(master.chainCode), "7923408dadd3c7b56eed15567707ae5e5dca089de972e07f3b860450e2a3b70e") let H = BIP32.hardenedBit for (level, path, expected) in [ (1, [H | 44], "cd46049fb82a4fccbd967c5234e61152f4df87d01d1ef833420814c67ee4b7ea"), (2, [H | 44, H | 144], "a12250f1f38fb41b62f0f1a4ec9d3555d24788843cb30e6d98a777fe540adc1d"), (3, [H | 44, H | 144, H | 0], "54bac451906ecd52648a49126a2606c9177761f14d31dda079c0f768ac3501fd"), (4, [H | 44, H | 144, H | 0, 0], "e582ce1724e93eebe3f38f1d42c01620b66d136946fec450dfe71b62e65de54f"), (5, BIP32.xrpPath, "90802a50aa84efb6cdb225f17c27616ea94048c179142fecf03f4712a07ea7a4"), ] { if let child = BIP32.derive(path, from: master) { expect("bip32 level \(level)", Hex.encode(child.privateKey), expected) } else { failures += 1 emit("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil") } } } else { failures += 1 emit("KOINS_SELFTEST bip32 master: FAIL") } let mnemonicCases: [(String, String, String)] = [ (zeroMnemonic, "031d68bc1a142e6766b2bdfb006ccfe135ef2e0e2e94abb5cf5c9ab6104776fbae", "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3"), ("legal winner thank year wave sausage worth useful legal winner thank yellow", "0283e0926eb0ab7bb8f5c0b5637d9e2526bdc398763af79cd904f013178961692d", "rpaLPza5oxhJ7V8nFDwPm9F7kmZ16uc8do"), ] for (position, vector) in mnemonicCases.enumerated() { do { let keypair = try XRPKey.keypair(fromMnemonic: vector.0) expect("bip44 pubkey #\(position)", Hex.encode(keypair.publicKey), vector.1) expect("bip44 address #\(position)", keypair.address, vector.2) } catch { failures += 1 emit("KOINS_SELFTEST bip44 #\(position): FAIL \(error)") } } // secp256k1 signing must produce a low-S DER signature. if let keypair = try? XRPKey.keypair(fromSeed: secpSeed), let message = Hex.decode(signingHex), let signature = try? XRPKey.sign(message: message, keypair: keypair) { let isDer = signature.count > 8 && signature[0] == 0x30 expect("secp256k1 signature is DER", isDer ? "yes" : "no", "yes") if let order = BigNumber.fromHex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"), let half = order.dividedByTwo(), let s = DER.signatureScalar(signature) { expect("secp256k1 signature is low-S", s.lessThanOrEqual(half) ? "yes" : "no", "yes") } emit("KOINS_SELFTEST secp256k1 signature len: \(signature.count)") } else { failures += 1 emit("KOINS_SELFTEST secp256k1 sign: FAIL") } emit("KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")") flushReport() } } /// Minimal big-endian integer helper used only for the low-S check. struct BigNumber { let bytes: [UInt8] static func fromHex(_ hex: String) -> BigNumber? { Hex.decode(hex).map { BigNumber(bytes: $0) } } func dividedByTwo() -> BigNumber? { var result: [UInt8] = [] var carry: UInt16 = 0 for byte in bytes { let value = carry * 256 + UInt16(byte) result.append(UInt8(value / 2)) carry = value % 2 } return BigNumber(bytes: result) } func lessThanOrEqual(_ other: BigNumber) -> Bool { let left = trimmed() let right = other.trimmed() if left.count != right.count { return left.count < right.count } for (a, b) in zip(left, right) where a != b { return a < b } return true } private func trimmed() -> [UInt8] { var value = bytes while value.first == 0 && value.count > 1 { value.removeFirst() } return value } } enum DER { /// Extracts the `s` component from a DER-encoded ECDSA signature. static func signatureScalar(_ der: [UInt8]) -> BigNumber? { guard der.count > 8, der[0] == 0x30 else { return nil } var index = 2 guard der[index] == 0x02 else { return nil } let rLength = Int(der[index + 1]) index += 2 + rLength guard index + 2 <= der.count, der[index] == 0x02 else { return nil } let sLength = Int(der[index + 1]) let start = index + 2 guard start + sLength <= der.count else { return nil } return BigNumber(bytes: Array(der[start..<(start + sLength)])) } }