import CryptoKit import Foundation enum XRPAlgorithm: String { case ed25519 case secp256k1 } /// Set by the app target so this file stays compilable outside iOS. enum Secp256k1Backend { static var derivePrivateKey: ((Data) -> Data?)? static var publicKey: ((Data) -> Data?)? static var sign: ((Data, Data) -> Data?)? static var addScalar: ((Data, Data) -> Data?)? } struct XRPKeypair { let algorithm: XRPAlgorithm /// ed25519: 32-byte seed. secp256k1: 32-byte scalar. let privateKey: [UInt8] /// ed25519: 0xED + 32-byte key. secp256k1: 33-byte compressed point. let publicKey: [UInt8] var address: String { XRPKey.address(fromPublicKey: publicKey) } var signingPublicKeyHex: String { Hex.encode(publicKey) } } enum XRPKey { static let ed25519SeedVersion: [UInt8] = [0x01, 0xe1, 0x4b] static let familySeedVersion: [UInt8] = [0x21] static let accountIdVersion: [UInt8] = [0x00] static let ed25519Prefix: UInt8 = 0xED // MARK: - Seeds static func generateSeed(algorithm: XRPAlgorithm = .ed25519) -> String { var entropy = [UInt8](repeating: 0, count: 16) for index in entropy.indices { entropy[index] = UInt8.random(in: 0...255) } let version = algorithm == .ed25519 ? ed25519SeedVersion : familySeedVersion return XRPBase58.checkEncode(version: version, payload: entropy) } static func decodeSeed(_ seed: String) throws -> (entropy: [UInt8], algorithm: XRPAlgorithm) { let decoded = try XRPBase58.decode(seed.trimmingCharacters(in: .whitespacesAndNewlines)) guard decoded.count > 4 else { throw XRPError.invalidSeed } let body = Array(decoded[0..<(decoded.count - 4)]) let checksum = Array(decoded[(decoded.count - 4)...]) guard XRPHash.checksum(body) == checksum else { throw XRPError.invalidSeed } if body.count == ed25519SeedVersion.count + 16, Array(body.prefix(3)) == ed25519SeedVersion { return (Array(body.dropFirst(3)), .ed25519) } if body.count == familySeedVersion.count + 16, body[0] == familySeedVersion[0] { return (Array(body.dropFirst(1)), .secp256k1) } throw XRPError.unsupportedSeed } // MARK: - Keys /// Recovery phrase for a new wallet: 12 BIP-39 words. static func generateMnemonic() -> String { BIP39.generateMnemonic() } /// Accepts either a 12 word BIP-39 recovery phrase or a legacy XRP family /// seed (`sEd…` / `s…`). static func keypair(fromSecret secret: String) throws -> XRPKeypair { let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) if BIP39.isMnemonic(trimmed) { return try keypair(fromMnemonic: trimmed) } return try keypair(fromSeed: trimmed) } /// Derives the account at `m/44'/144'/0'/0/0`, the path hardware and mobile /// wallets use for XRP. static func keypair(fromMnemonic mnemonic: String) throws -> XRPKeypair { guard BIP39.entropy(from: mnemonic) != nil else { throw XRPError.invalidSeed } let seed = BIP39.seed(from: mnemonic) guard let master = BIP32.masterKey(fromSeed: seed), let derived = BIP32.derive(BIP32.xrpPath, from: master) else { throw XRPError.unsupportedSeed } return XRPKeypair(algorithm: .secp256k1, privateKey: derived.privateKey, publicKey: derived.publicKey) } static func keypair(fromSeed seed: String) throws -> XRPKeypair { let (entropy, algorithm) = try decodeSeed(seed) switch algorithm { case .ed25519: let raw = XRPHash.sha512Half(entropy) let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(raw)) let publicKey = [ed25519Prefix] + Array(privateKey.publicKey.rawRepresentation) return XRPKeypair(algorithm: .ed25519, privateKey: raw, publicKey: publicKey) case .secp256k1: guard let derive = Secp256k1Backend.derivePrivateKey, let toPublic = Secp256k1Backend.publicKey, let privateKey = derive(Data(entropy)), let publicKey = toPublic(privateKey) else { throw XRPError.unsupportedSeed } return XRPKeypair(algorithm: .secp256k1, privateKey: Array(privateKey), publicKey: Array(publicKey)) } } static func address(fromPublicKey publicKey: [UInt8]) -> String { let accountId = XRPHash.ripemd160(XRPHash.sha256(publicKey)) return XRPBase58.checkEncode(version: accountIdVersion, payload: accountId) } static func isValidAddress(_ address: String) -> Bool { (try? XRPBase58.checkDecode(address, versionLength: 1)) != nil } static func accountId(fromAddress address: String) throws -> [UInt8] { let payload = try XRPBase58.checkDecode(address, versionLength: 1) guard payload.count == 20 else { throw XRPError.invalidAddress } return payload } // MARK: - Signing /// ed25519 returns a 64-byte signature; secp256k1 a DER-encoded one, matching /// what the XRP Ledger expects for each signing scheme. static func sign(message: [UInt8], keypair: XRPKeypair) throws -> [UInt8] { switch keypair.algorithm { case .ed25519: let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: Data(keypair.privateKey)) let signature = try privateKey.signature(for: Data(message)) return Array(signature) case .secp256k1: let digest = XRPHash.sha512Half(message) guard let signFn = Secp256k1Backend.sign, let signature = signFn(Data(digest), Data(keypair.privateKey)) else { throw XRPError.unsupportedSeed } return Array(signature) } } }