Replace the JavaScript mobile app with a native Swift iOS wallet
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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 func emit(_ line: String) {
|
||||
print(line)
|
||||
os_log("%{public}@", log: log, type: .default, line)
|
||||
}
|
||||
|
||||
static func runIfRequested() {
|
||||
if ProcessInfo.processInfo.arguments.contains("-koinsSelfTest") {
|
||||
print("KOINS_SELFTEST begin")
|
||||
run()
|
||||
print("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)")
|
||||
await MoneroService.shared.stopSync()
|
||||
await MoneroService.shared.removeWallet(name: "koins-net-selftest")
|
||||
} catch {
|
||||
emit("KOINS_NET xmr FAIL \(error)")
|
||||
}
|
||||
|
||||
restoreWallets()
|
||||
emit("KOINS_NET end")
|
||||
}
|
||||
}
|
||||
|
||||
static func run() {
|
||||
var failures = 0
|
||||
|
||||
func expect(_ name: String, _ actual: String, _ wanted: String) {
|
||||
let ok = actual.lowercased() == wanted.lowercased()
|
||||
if !ok { failures += 1 }
|
||||
let line = "KOINS_SELFTEST \(name): \(ok ? "PASS" : "FAIL") \(actual)"
|
||||
print(line)
|
||||
os_log("%{public}@", log: log, type: .default, line)
|
||||
}
|
||||
|
||||
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
|
||||
print("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
|
||||
print("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
|
||||
print("KOINS_SELFTEST bip32 level \(level): FAIL derivation returned nil")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
failures += 1
|
||||
print("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
|
||||
print("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")
|
||||
}
|
||||
print("KOINS_SELFTEST secp256k1 signature len: \(signature.count)")
|
||||
} else {
|
||||
failures += 1
|
||||
print("KOINS_SELFTEST secp256k1 sign: FAIL")
|
||||
}
|
||||
|
||||
let summary = "KOINS_SELFTEST summary: \(failures == 0 ? "ALL PASS" : "\(failures) FAILURE(S)")"
|
||||
print(summary)
|
||||
os_log("%{public}@", log: log, type: .default, summary)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user