diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift new file mode 100644 index 00000000000..63661f4a7e4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/DataTransformers.swift @@ -0,0 +1,281 @@ +import Foundation + +// MARK: - Address Transformer + +/// Transforms and converts address data between different formats (hex, Data, bech32m). +public enum AddressTransformer { + + /// Converts a hex string to Data. + /// - Parameter hex: Hex string (must have even number of characters). + /// - Returns: Data if conversion succeeds, nil otherwise. + public static func hexToData(_ hex: String) -> Data? { + let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count % 2 == 0 else { return nil } + return Data(hexString: trimmed) + } + + /// Converts Data to a lowercase hex string. + /// - Parameter data: The data to convert. + /// - Returns: Hex string representation. + public static func dataToHex(_ data: Data) -> String { + data.toHexString() + } + + /// Normalizes an identity ID to Data, handling both base58 and hex formats. + /// - Parameter id: Identity ID in either base58 or hex format. + /// - Returns: 32-byte Data if valid, nil otherwise. + public static func normalizeIdentityId(_ id: String) -> Data? { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + // Check if it's a valid 64-character hex string (32 bytes) + if AddressValidator.isHexIdentityId(trimmed) { + return hexToData(trimmed) + } + + // Try base58 decoding + if let data = Data.identifier(fromBase58: trimmed), data.count == 32 { + return data + } + + return nil + } + + /// Parses a bech32m Platform address to its raw address bytes. + /// - Parameter address: Bech32m address string (dashevo1... or tdashevo1...). + /// - Returns: Address bytes (21 bytes) if valid, nil otherwise. + public static func parseBech32mAddress(_ address: String) -> Data? { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let result = Bech32m.decode(trimmed), + (result.hrp == "dashevo" || result.hrp == "tdashevo"), + result.data.count == 21 else { + return nil + } + return result.data + } + + /// Formats address bytes for display, optionally as bech32m. + /// - Parameters: + /// - data: Address bytes (21 bytes for Platform address). + /// - asBech32m: If true, encodes as bech32m; otherwise returns hex. + /// - isTestnet: If asBech32m is true, determines testnet (tdashevo1) vs mainnet (dashevo1). + /// - Returns: Formatted address string. + public static func formatAddress(_ data: Data, asBech32m: Bool = false, isTestnet: Bool = true) -> String { + if asBech32m, data.count == 21 { + let hrp = isTestnet ? "tdashevo" : "dashevo" + return Bech32m.encode(hrp: hrp, data: data) ?? dataToHex(data) + } + return dataToHex(data) + } + + /// Parses an address string in either hex or bech32m format to Data. + /// - Parameter address: Address string in hex (42 chars) or bech32m format. + /// - Returns: Address bytes if valid, nil otherwise. + public static func parseAddress(_ address: String) -> Data? { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if it's a bech32m address + if trimmed.lowercased().hasPrefix("dashevo1") || trimmed.lowercased().hasPrefix("tdashevo1") { + return parseBech32mAddress(trimmed) + } + + // Try hex format (42 characters = 21 bytes) + if trimmed.count == 42 { + return hexToData(trimmed) + } + + return nil + } +} + +// MARK: - Number Transformer + +/// Transforms and validates numeric strings for SDK operations. +public enum NumberTransformer { + + /// Parses a string to UInt64. + /// - Parameter string: String representation of a number. + /// - Returns: UInt64 if valid, nil otherwise. + public static func parseUInt64(_ string: String) -> UInt64? { + UInt64(string.trimmingCharacters(in: .whitespaces)) + } + + /// Parses a string to UInt32. + /// - Parameter string: String representation of a number. + /// - Returns: UInt32 if valid, nil otherwise. + public static func parseUInt32(_ string: String) -> UInt32? { + UInt32(string.trimmingCharacters(in: .whitespaces)) + } + + /// Parses an amount string, ensuring it's a positive number. + /// - Parameter amount: Amount string. + /// - Returns: UInt64 if valid positive number, nil otherwise. + public static func parseAmount(_ amount: String) -> UInt64? { + guard let value = parseUInt64(amount), value > 0 else { + return nil + } + return value + } + + /// Parses a fee string to UInt32. + /// - Parameter fee: Fee string (e.g., fee per byte). + /// - Returns: UInt32 if valid, nil otherwise. + public static func parseFee(_ fee: String) -> UInt32? { + parseUInt32(fee) + } + + /// Formats an amount in duffs/credits for display. + /// - Parameters: + /// - amount: Amount in smallest unit (duffs or credits). + /// - unit: Unit name for display (default: "credits"). + /// - Returns: Formatted string with unit. + public static func formatAmount(_ amount: UInt64, unit: String = "credits") -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = "," + let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" + return "\(formatted) \(unit)" + } + + /// Formats an amount converting from credits to Dash. + /// 1 Dash = 100,000,000,000 (10^11) credits. + /// - Parameter credits: Amount in credits. + /// - Returns: Formatted string in Dash. + public static func formatCreditsAsDash(_ credits: UInt64) -> String { + let dash = Double(credits) / 100_000_000_000.0 + return String(format: "%.8f Dash", dash) + } + + /// Formats an amount converting from duffs to Dash. + /// 1 Dash = 100,000,000 (10^8) duffs. + /// - Parameter duffs: Amount in duffs. + /// - Returns: Formatted string in Dash. + public static func formatDuffsAsDash(_ duffs: UInt64) -> String { + let dash = Double(duffs) / 100_000_000.0 + return String(format: "%.8f Dash", dash) + } +} + +// MARK: - Response Parser + +/// Parses and extracts data from SDK response objects. +public enum ResponseParser { + + /// Extracts address info from a PlatformAddressInfo result. + /// - Parameter info: The platform address info object. + /// - Returns: A tuple with balance and nonce. + public static func parseAddressInfo(_ info: PlatformAddressInfo) -> (balance: UInt64, nonce: UInt32) { + (balance: info.balance, nonce: info.nonce) + } + + /// Extracts results from a PlatformAddressInfosResult. + /// - Parameter result: The result object from a transfer or top-up operation. + /// - Returns: Array of address info tuples (address hex, balance, nonce). + public static func parseAddressInfosResult(_ result: PlatformAddressInfosResult) -> [(address: String, balance: UInt64, nonce: UInt32)] { + result.infos.map { (addressData, info) in + ( + address: AddressTransformer.dataToHex(addressData), + balance: info.balance, + nonce: info.nonce + ) + } + } + + /// Checks if a transfer result indicates success. + /// - Parameter result: The result from a transfer operation. + /// - Returns: True if the transfer was successful (has addresses). + public static func isTransferSuccessful(_ result: PlatformAddressInfosResult) -> Bool { + !result.infos.isEmpty + } + + /// Formats a PlatformAddressInfo for display. + /// - Parameters: + /// - info: The address info to format. + /// - includeAddress: Whether to include the address in the output. + /// - Returns: Formatted string. + public static func formatAddressInfo(_ info: PlatformAddressInfo, includeAddress: Bool = true) -> String { + var parts: [String] = [] + if includeAddress { + parts.append("Address: \(AddressTransformer.dataToHex(info.addressBytes))") + } + parts.append("Balance: \(NumberTransformer.formatAmount(info.balance))") + parts.append("Nonce: \(info.nonce)") + return parts.joined(separator: "\n") + } +} + +// MARK: - Transfer Input Builder + +/// Helps build transfer inputs and outputs from form data. +public enum TransferInputBuilder { + + /// Creates an AddressTransferInput from string parameters. + /// - Parameters: + /// - addressHex: Address in hex format (42 characters). + /// - amount: Amount as string. + /// - nonce: Nonce (defaults to 0). + /// - privateKeyHex: Private key in hex format (64 characters). + /// - Returns: AddressTransferInput if all parameters are valid, nil otherwise. + public static func createInput( + addressHex: String, + amount: String, + nonce: UInt32 = 0, + privateKeyHex: String + ) -> Addresses.AddressTransferInput? { + guard let addressData = AddressTransformer.hexToData(addressHex), + let amountValue = NumberTransformer.parseUInt64(amount), + let privateKeyData = AddressTransformer.hexToData(privateKeyHex) + else { + return nil + } + + return Addresses.AddressTransferInput( + addressBytes: addressData, + amount: amountValue, + nonce: nonce, + privateKey: privateKeyData + ) + } + + /// Creates an AddressTransferOutput from string parameters. + /// - Parameters: + /// - addressHex: Address in hex format (42 characters). + /// - amount: Amount as string. + /// - Returns: AddressTransferOutput if all parameters are valid, nil otherwise. + public static func createOutput( + addressHex: String, + amount: String + ) -> Addresses.AddressTransferOutput? { + guard let addressData = AddressTransformer.hexToData(addressHex), + let amountValue = NumberTransformer.parseUInt64(amount) + else { + return nil + } + + return Addresses.AddressTransferOutput( + addressBytes: addressData, + amount: amountValue + ) + } + + /// Creates an AddressTransferOutput from string parameters, supporting both hex and bech32m addresses. + /// - Parameters: + /// - address: Address in hex (42 characters) or bech32m format. + /// - amount: Amount as string. + /// - Returns: AddressTransferOutput if all parameters are valid, nil otherwise. + public static func createOutputUniversal( + address: String, + amount: String + ) -> Addresses.AddressTransferOutput? { + guard let addressData = AddressTransformer.parseAddress(address), + let amountValue = NumberTransformer.parseUInt64(amount) + else { + return nil + } + + return Addresses.AddressTransferOutput( + addressBytes: addressData, + amount: amountValue + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/PrivateKeyUtils.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/PrivateKeyUtils.swift new file mode 100644 index 00000000000..76ce3cc8fa8 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/PrivateKeyUtils.swift @@ -0,0 +1,309 @@ +import Foundation + +// MARK: - Key Format + +/// Detected format of a private key input. +public enum PrivateKeyFormat { + case hex // 64 hex characters (32 bytes) + case wif // WIF-encoded private key + case unknown // Could not detect format +} + +// MARK: - Key Format Detector + +/// Detects the format of a private key string. +public enum KeyFormatDetector { + + private static let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + + /// Detects the format of a private key string. + /// - Parameter input: The private key string to analyze. + /// - Returns: The detected format. + public static func detectFormat(_ input: String) -> PrivateKeyFormat { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .unknown } + + // Check for hex format (64 hex characters = 32 bytes) + if trimmed.count == 64 && isHex(trimmed) { + return .hex + } + + // Check for WIF format (starts with specific characters) + if isLikelyWIF(trimmed) { + return .wif + } + + return .unknown + } + + /// Checks if a string contains only hex characters. + /// - Parameter string: The string to check. + /// - Returns: True if the string is valid hex. + public static func isHex(_ string: String) -> Bool { + string.unicodeScalars.allSatisfy { hexCharacterSet.contains($0) } + } + + /// Checks if a string looks like a WIF-encoded key. + /// - Parameter string: The string to check. + /// - Returns: True if the string appears to be WIF format. + public static func isLikelyWIF(_ string: String) -> Bool { + guard string.count >= 51 && string.count <= 52 else { return false } + + // WIF keys start with specific characters: + // - Mainnet uncompressed: '5' + // - Mainnet compressed: 'K' or 'L' + // - Testnet uncompressed: '9' + // - Testnet compressed: 'c' + // - Dash mainnet: '7' or 'X' + // - Dash testnet: 'c' or 'X' + let firstChar = string.first! + let wifPrefixes: Set = ["5", "K", "L", "9", "c", "7", "X"] + return wifPrefixes.contains(firstChar) + } +} + +// MARK: - Private Key Parser + +/// Parses private keys from various formats (hex, WIF). +public enum PrivateKeyParser { + + /// Parse result with optional error message. + public struct ParseResult { + public let data: Data? + public let format: PrivateKeyFormat + public let error: String? + + public var isValid: Bool { data != nil } + + public static func success(_ data: Data, format: PrivateKeyFormat) -> ParseResult { + ParseResult(data: data, format: format, error: nil) + } + + public static func failure(_ error: String, format: PrivateKeyFormat = .unknown) -> ParseResult { + ParseResult(data: nil, format: format, error: error) + } + } + + /// Parse a private key from hex or WIF format. + /// - Parameter input: The private key string. + /// - Returns: ParseResult with the key data or error message. + public static func parse(_ input: String) -> ParseResult { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .failure("Private key is empty") + } + + let format = KeyFormatDetector.detectFormat(trimmed) + + switch format { + case .hex: + return parseHex(trimmed) + case .wif: + return parseWIF(trimmed) + case .unknown: + // Try both formats + if let hexResult = tryParseHex(trimmed) { + return .success(hexResult, format: .hex) + } + if let wifResult = WIFParser.parseWIF(trimmed) { + return .success(wifResult, format: .wif) + } + return .failure("Invalid private key format. Expected 64 hex characters or WIF format.") + } + } + + /// Parse a hex-encoded private key. + /// - Parameter hex: The hex string (must be 64 characters). + /// - Returns: ParseResult with the key data or error message. + public static func parseHex(_ hex: String) -> ParseResult { + let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) + + guard trimmed.count == 64 else { + return .failure("Hex private key must be 64 characters (32 bytes), got \(trimmed.count)") + } + + guard KeyFormatDetector.isHex(trimmed) else { + return .failure("Invalid hex characters in private key") + } + + guard let data = AddressTransformer.hexToData(trimmed) else { + return .failure("Failed to parse hex private key") + } + + return .success(data, format: .hex) + } + + /// Parse a WIF-encoded private key. + /// - Parameter wif: The WIF string. + /// - Returns: ParseResult with the key data or error message. + public static func parseWIF(_ wif: String) -> ParseResult { + let trimmed = wif.trimmingCharacters(in: .whitespacesAndNewlines) + + guard let data = WIFParser.parseWIF(trimmed) else { + return .failure("Invalid WIF format or checksum") + } + + guard data.count == 32 else { + return .failure("WIF decoded to \(data.count) bytes, expected 32") + } + + return .success(data, format: .wif) + } + + /// Try to parse a hex string without strict validation. + private static func tryParseHex(_ input: String) -> Data? { + guard input.count == 64, KeyFormatDetector.isHex(input) else { return nil } + return AddressTransformer.hexToData(input) + } +} + +// MARK: - Key Validator + +/// Validates private keys and matches them to public keys. +public enum KeyValidator { + + /// Validation result with details. + public struct ValidationResult { + public let isValid: Bool + public let matchedKey: IdentityPublicKey? + public let error: String? + + public static func valid(matchedKey: IdentityPublicKey) -> ValidationResult { + ValidationResult(isValid: true, matchedKey: matchedKey, error: nil) + } + + public static func invalid(_ error: String) -> ValidationResult { + ValidationResult(isValid: false, matchedKey: nil, error: error) + } + } + + /// Validates a private key against an identity's public keys. + /// - Parameters: + /// - privateKey: The private key data (32 bytes). + /// - publicKeys: List of public keys to match against. + /// - isTestnet: Whether to use testnet parameters. + /// - Returns: ValidationResult with the matched key or error. + public static func validatePrivateKey( + _ privateKey: Data, + against publicKeys: [IdentityPublicKey], + isTestnet: Bool = true + ) -> ValidationResult { + guard privateKey.count == 32 else { + return .invalid("Private key must be 32 bytes, got \(privateKey.count)") + } + + guard !publicKeys.isEmpty else { + return .invalid("No public keys to validate against") + } + + if let matchedKey = KeyValidation.matchPrivateKeyToPublicKeys( + privateKeyData: privateKey, + publicKeys: publicKeys, + isTestnet: isTestnet + ) { + return .valid(matchedKey: matchedKey) + } + + return .invalid("Private key does not match any public key") + } + + /// Validates a private key string against an identity's public keys. + /// - Parameters: + /// - privateKeyInput: The private key string (hex or WIF). + /// - publicKeys: List of public keys to match against. + /// - isTestnet: Whether to use testnet parameters. + /// - Returns: ValidationResult with the matched key or error. + public static func validatePrivateKeyInput( + _ privateKeyInput: String, + against publicKeys: [IdentityPublicKey], + isTestnet: Bool = true + ) -> ValidationResult { + let parseResult = PrivateKeyParser.parse(privateKeyInput) + + guard let privateKey = parseResult.data else { + return .invalid(parseResult.error ?? "Failed to parse private key") + } + + return validatePrivateKey(privateKey, against: publicKeys, isTestnet: isTestnet) + } +} + +// MARK: - Key Size Validator + +/// Validates key sizes for different key types. +public enum KeySizeValidator { + + /// Expected private key size for a given key type. + /// - Parameter keyType: The type of key. + /// - Returns: Expected size in bytes. + public static func expectedPrivateKeySize(for keyType: KeyType) -> Int { + switch keyType { + case .ecdsaSecp256k1: + return 32 // 256 bits + case .bls12_381: + return 32 // 256 bits + case .ecdsaHash160: + return 32 // 256 bits for the actual key + case .bip13ScriptHash: + return 32 // 256 bits + case .eddsa25519Hash160: + return 32 // 256 bits + } + } + + /// Validates that a private key has the correct size for its type. + /// - Parameters: + /// - privateKey: The private key data. + /// - keyType: The type of key. + /// - Returns: True if the size is correct. + public static func isValidSize(_ privateKey: Data, for keyType: KeyType) -> Bool { + privateKey.count == expectedPrivateKeySize(for: keyType) + } +} + +// MARK: - Key Formatter + +/// Formats keys for display and export. +public enum KeyFormatter { + + /// Format a private key as hex string. + /// - Parameter privateKey: The private key data. + /// - Returns: Hex string representation. + public static func toHex(_ privateKey: Data) -> String { + AddressTransformer.dataToHex(privateKey) + } + + /// Format a private key as WIF string. + /// - Parameters: + /// - privateKey: The private key data. + /// - isTestnet: Whether to encode for testnet. + /// - Returns: WIF string or nil if encoding fails. + public static func toWIF(_ privateKey: Data, isTestnet: Bool = true) -> String? { + WIFParser.encodeToWIF(privateKey, isTestnet: isTestnet) + } + + /// Format a public key for display. + /// - Parameters: + /// - publicKey: The public key. + /// - truncate: If true, shows only first and last 8 characters. + /// - Returns: Formatted string. + public static func formatPublicKey(_ publicKey: IdentityPublicKey, truncate: Bool = false) -> String { + let hex = AddressTransformer.dataToHex(publicKey.data) + if truncate && hex.count > 20 { + return "\(hex.prefix(8))...\(hex.suffix(8))" + } + return hex + } + + /// Format key information for display. + /// - Parameter publicKey: The public key to format. + /// - Returns: Multi-line description of the key. + public static func formatKeyInfo(_ publicKey: IdentityPublicKey) -> String { + """ + Key ID: #\(publicKey.id) + Purpose: \(publicKey.purpose.name) + Type: \(publicKey.keyType.name) + Security: \(publicKey.securityLevel.name) + """ + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift index 0986c26fb9f..c084120061f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Utils/Validation.swift @@ -58,6 +58,41 @@ public enum AddressValidator { public static func validateIdentityId(_ id: String) -> Bool { !id.trimmingCharacters(in: .whitespaces).isEmpty } + + /// Validates a bech32m Platform address (dashevo1... or tdashevo1...). + /// - Returns: `true` if string is a valid bech32m address with correct HRP and 21 bytes of data. + public static func validateBech32mAddress(_ address: String) -> Bool { + Bech32m.isValidPlatformAddress(address) + } + + /// Validates an address in either hex (42 chars) or bech32m format. + /// - Returns: `true` if string is valid in either format. + public static func validateAddress(_ address: String) -> Bool { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("dashevo1") || trimmed.lowercased().hasPrefix("tdashevo1") { + return validateBech32mAddress(trimmed) + } + return validateHexAddress(trimmed) + } + + /// Validates a 32-byte hash in hex format (64 characters). + /// - Returns: `true` if string is exactly 64 hex characters. + public static func validateHash(_ hex: String) -> Bool { + validateHexString(hex, byteLength: 32) + } + + /// Validates an identity ID in hex format (64 characters = 32 bytes). + /// - Returns: `true` if string is exactly 64 hex characters. + public static func validateIdentityIdHex(_ hex: String) -> Bool { + validateHexString(hex, byteLength: 32) + } + + /// Checks if a string is a valid hex identity ID (64 chars). + /// Useful for format detection (hex vs base58). + /// - Returns: `true` if string is exactly 64 hex characters. + public static func isHexIdentityId(_ id: String) -> Bool { + validateHexString(id, byteLength: 32) + } } // MARK: - Transfer Input Validator diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/AddressTransferViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/AddressTransferViewModel.swift index 4f25367906d..d7dc01e1db1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/AddressTransferViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/AddressTransferViewModel.swift @@ -53,11 +53,16 @@ final class AddressTransferViewModel: BaseViewModel { /// Execute the transfer using the given SDK. Updates result or errorMessage on main actor. func executeTransfer(sdk: SDK) async { - guard let inputAddressData = Data(hexString: inputAddressHex), - let privateKeyData = Data(hexString: inputPrivateKeyHex), - let outputAddressData = Data(hexString: outputAddressHex), - let inputAmt = UInt64(inputAmount), - let outputAmt = UInt64(outputAmount) + guard let input = TransferInputBuilder.createInput( + addressHex: inputAddressHex, + amount: inputAmount, + nonce: 0, + privateKeyHex: inputPrivateKeyHex + ), + let output = TransferInputBuilder.createOutput( + addressHex: outputAddressHex, + amount: outputAmount + ) else { errorMessage = "Invalid input data" showResult = true @@ -70,20 +75,8 @@ final class AddressTransferViewModel: BaseViewModel { showResult = false do { - let inputs = [ - Addresses.AddressTransferInput( - addressBytes: inputAddressData, - amount: inputAmt, - nonce: 0, - privateKey: privateKeyData - ) - ] - let outputs = [ - Addresses.AddressTransferOutput( - addressBytes: outputAddressData, - amount: outputAmt - ) - ] + let inputs = [input] + let outputs = [output] let transferResult = try sdk.addresses.transferFunds( inputs: inputs, outputs: outputs, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/TopUpAddressFromAssetLockViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/TopUpAddressFromAssetLockViewModel.swift index 4c10125fcba..298f250d2a2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/TopUpAddressFromAssetLockViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/TopUpAddressFromAssetLockViewModel.swift @@ -48,15 +48,15 @@ final class TopUpAddressFromAssetLockViewModel: BaseViewModel { } func executeTopUp(sdk: SDK) async { - guard let outputAddressData = Data(hexString: outputAddressHex), - let privateKeyData = Data(hexString: assetLockPrivateKeyHex) + guard let outputAddressData = AddressTransformer.hexToData(outputAddressHex), + let privateKeyData = AddressTransformer.hexToData(assetLockPrivateKeyHex) else { errorMessage = "Invalid input data" showResult = true return } - let outputAmountValue = UInt64(outputAmount) ?? 0 + let outputAmountValue = NumberTransformer.parseUInt64(outputAmount) ?? 0 isLoading = true errorMessage = nil result = nil @@ -72,9 +72,9 @@ final class TopUpAddressFromAssetLockViewModel: BaseViewModel { let topUpResult: PlatformAddressInfosResult if proofType == .instant { - guard let instantLockData = Data(hexString: instantLockHex), - let transactionData = Data(hexString: transactionHex), - let outputIdx = UInt32(outputIndex) + guard let instantLockData = AddressTransformer.hexToData(instantLockHex), + let transactionData = AddressTransformer.hexToData(transactionHex), + let outputIdx = NumberTransformer.parseUInt32(outputIndex) else { errorMessage = "Invalid instant lock data" showResult = true @@ -92,8 +92,8 @@ final class TopUpAddressFromAssetLockViewModel: BaseViewModel { outputs: outputs ) } else { - guard let outPointData = Data(hexString: outPointHex), - let height = UInt32(coreChainLockedHeight) + guard let outPointData = AddressTransformer.hexToData(outPointHex), + let height = NumberTransformer.parseUInt32(coreChainLockedHeight) else { errorMessage = "Invalid chain lock data" showResult = true diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/WithdrawAddressFundsViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/WithdrawAddressFundsViewModel.swift index 67c833f50ca..fae0433dbc6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/WithdrawAddressFundsViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ViewModels/WithdrawAddressFundsViewModel.swift @@ -44,31 +44,27 @@ final class WithdrawAddressFundsViewModel: BaseViewModel { } func executeWithdrawal(sdk: SDK) async { - guard let inputAddressData = Data(hexString: inputAddressHex), - let privateKeyData = Data(hexString: inputPrivateKeyHex), - let inputAmt = UInt64(inputAmount) + guard let input = TransferInputBuilder.createInput( + addressHex: inputAddressHex, + amount: inputAmount, + nonce: 0, + privateKeyHex: inputPrivateKeyHex + ) else { errorMessage = "Invalid input data" showResult = true return } - let coreFee = UInt32(coreFeePerByte) ?? 0 + let coreFee = NumberTransformer.parseFee(coreFeePerByte) ?? 0 isLoading = true errorMessage = nil result = nil showResult = false do { - let inputs = [ - Addresses.AddressTransferInput( - addressBytes: inputAddressData, - amount: inputAmt, - nonce: 0, - privateKey: privateKeyData - ) - ] - let changeAddressData: Data? = useChangeAddress ? Data(hexString: changeAddressHex) : nil + let inputs = [input] + let changeAddressData: Data? = useChangeAddress ? AddressTransformer.hexToData(changeAddressHex) : nil let withdrawalResult = try sdk.addresses.withdrawFunds( inputs: inputs, coreAddress: coreAddress, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift index b843f90d1aa..f6679486f90 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/AddressQueriesView.swift @@ -943,7 +943,7 @@ struct GetBranchStateView: View { private var isFormValid: Bool { guard let depthValue = UInt32(depth) else { return false } - return !keyHex.isEmpty && !expectedHashHex.isEmpty && expectedHashHex.count == 64 + return !keyHex.isEmpty && AddressValidator.validateHash(expectedHashHex) && !checkpointHeight.isEmpty && depthValue >= 6 && depthValue <= 9 && UInt64(checkpointHeight) != nil } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift index 807989e3e2c..761e36a950a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift @@ -167,9 +167,9 @@ struct DocumentWithPriceView: View { return data.count == 32 } - // Check if it's a valid hex string (64 characters) - if id.count == 64 { - return id.allSatisfy { $0.isHexDigit } + // Check if it's a valid hex string (64 characters = 32 bytes) + if AddressValidator.isHexIdentityId(id) { + return true } return false diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index 4a47fdb3731..88693e1ae23 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -137,101 +137,55 @@ struct KeyDetailView: View { private func validateAndStorePrivateKey() { isValidating = true validationError = nil - + Task { - // Parse the private key input - let trimmedInput = privateKeyInput.trimmingCharacters(in: .whitespacesAndNewlines) - - // Convert to Data (hex or WIF format) - guard let privateKeyData = parsePrivateKey(trimmedInput) else { - await MainActor.run { - validationError = "Invalid private key format" - isValidating = false - } - return - } - - // Ensure SDK exists - guard appState.sdk != nil else { - await MainActor.run { - validationError = "SDK not initialized" - isValidating = false - } - return + // Parse the private key using centralized parser + let parseResult = PrivateKeyParser.parse(privateKeyInput) + + guard let privateKeyData = parseResult.data else { + await MainActor.run { + validationError = parseResult.error ?? "Invalid private key format" + isValidating = false } - - // Get the public key data in the correct format - let publicKeyHex: String - if publicKey.keyType == .ecdsaHash160 || publicKey.keyType == .eddsa25519Hash160 { - // For hash160 types, the data is already the hash - publicKeyHex = publicKey.data.toHexString() - } else { - // For other types, we need the full public key - publicKeyHex = publicKey.data.toHexString() + return + } + + // Ensure SDK exists + guard appState.sdk != nil else { + await MainActor.run { + validationError = "SDK not initialized" + isValidating = false } - - // Validate the private key matches the public key - let isValid = KeyValidation.validatePrivateKeyForPublicKey( - privateKeyHex: privateKeyData.toHexString(), - publicKeyHex: publicKeyHex, - keyType: publicKey.keyType + return + } + + // Validate the private key matches the public key using centralized validator + let validationResult = KeyValidator.validatePrivateKey( + privateKeyData, + against: [publicKey] + ) + + if validationResult.isValid { + // Store the private key + print("🔑 Storing private key for identity: \(identity.id.toHexString()), keyId: \(publicKey.id)") + let stored = KeychainManager.shared.storePrivateKey( + privateKeyData, + identityId: identity.id, + keyIndex: Int32(publicKey.id) ) - - if isValid { - // Store the private key - print("🔑 Storing private key for identity: \(identity.id.toHexString()), keyId: \(publicKey.id)") - let stored = KeychainManager.shared.storePrivateKey( - privateKeyData, - identityId: identity.id, - keyIndex: Int32(publicKey.id) - ) - print("🔑 Storage result: \(stored != nil ? "Success" : "Failed")") - - await MainActor.run { - showSuccessAlert = true - isValidating = false - } - } else { - await MainActor.run { - validationError = "Private key does not match the public key" - isValidating = false - } + print("🔑 Storage result: \(stored != nil ? "Success" : "Failed")") + + await MainActor.run { + showSuccessAlert = true + isValidating = false + } + } else { + await MainActor.run { + validationError = validationResult.error ?? "Private key does not match the public key" + isValidating = false } - } - } - - private func parsePrivateKey(_ input: String) -> Data? { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - - // Try hex first - if let hexData = Data(hexString: trimmed) { - // Validate it's 32 bytes for a private key - if hexData.count == 32 { - return hexData } } - - // Try WIF format - if let wifData = WIFParser.parseWIF(trimmed) { - return wifData - } - - return nil - } - - private func validateKeySize(_ privateKey: Data, for keyType: KeyType) -> Bool { - switch keyType { - case .ecdsaSecp256k1: - return privateKey.count == 32 // 256 bits - case .bls12_381: - return privateKey.count == 32 // 256 bits - case .ecdsaHash160: - return privateKey.count == 32 // 256 bits for the actual key - case .bip13ScriptHash: - return privateKey.count == 32 // 256 bits - case .eddsa25519Hash160: - return privateKey.count == 32 // 256 bits - } } private func forgetPrivateKey() { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index 937d2dcfd79..f2bfcdf1cd5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -1934,8 +1934,8 @@ struct TransitionDetailView: View { .replacingOccurrences(of: "0x", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) - // If it's hex (64 chars), convert to base58 - if cleanId.count == 64, let data = Data(hexString: cleanId) { + // If it's hex (64 chars = 32 bytes), convert to base58 + if AddressValidator.isHexIdentityId(cleanId), let data = Data(hexString: cleanId) { return data.toBase58String() } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/DataTransformersTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/DataTransformersTests.swift new file mode 100644 index 00000000000..97529c7a01a --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/DataTransformersTests.swift @@ -0,0 +1,280 @@ +import XCTest +@testable import SwiftDashSDK + +final class DataTransformersTests: XCTestCase { + + // MARK: - AddressTransformer Tests + + func testHexToDataValid() { + // 42-character hex string (21 bytes) - typical Platform address + let hex = "001234567890abcdef1234567890abcdef12345678" + let data = AddressTransformer.hexToData(hex) + XCTAssertNotNil(data) + XCTAssertEqual(data?.count, 21) + } + + func testHexToDataEmpty() { + let data = AddressTransformer.hexToData("") + XCTAssertNil(data) + } + + func testHexToDataOddLength() { + let data = AddressTransformer.hexToData("abc") + XCTAssertNil(data) + } + + func testHexToDataInvalidCharacters() { + let data = AddressTransformer.hexToData("gggg") + XCTAssertNil(data) + } + + func testHexToDataWithWhitespace() { + let hex = " aabb " + let data = AddressTransformer.hexToData(hex) + XCTAssertNotNil(data) + XCTAssertEqual(data?.count, 2) + } + + func testDataToHex() { + let data = Data([0x00, 0xab, 0xcd, 0xef]) + let hex = AddressTransformer.dataToHex(data) + XCTAssertEqual(hex, "00abcdef") + } + + func testDataToHexEmpty() { + let data = Data() + let hex = AddressTransformer.dataToHex(data) + XCTAssertEqual(hex, "") + } + + func testNormalizeIdentityIdHex() { + // 64-character hex string (32 bytes) + let hexId = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + let data = AddressTransformer.normalizeIdentityId(hexId) + XCTAssertNotNil(data) + XCTAssertEqual(data?.count, 32) + } + + func testNormalizeIdentityIdBase58() { + // Valid base58 that decodes to 32 bytes + // Using a known base58 encoded 32-byte value + let base58Id = "5J3mBbAH58CpQ3Y5RNJpUKPE62SQ5tfcvU2JpbnkeyhfsYB1Jcn" + let data = AddressTransformer.normalizeIdentityId(base58Id) + // May or may not equal 32 bytes depending on the actual encoding + // This tests that base58 decoding is attempted + XCTAssertTrue(data == nil || data?.count == 32) + } + + func testNormalizeIdentityIdEmpty() { + let data = AddressTransformer.normalizeIdentityId("") + XCTAssertNil(data) + } + + func testNormalizeIdentityIdInvalidHex() { + // 64 characters but not hex + let invalid = "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg" + let data = AddressTransformer.normalizeIdentityId(invalid) + XCTAssertNil(data) + } + + func testParseAddressHex() { + let hex = "001234567890abcdef1234567890abcdef12345678" + let data = AddressTransformer.parseAddress(hex) + XCTAssertNotNil(data) + XCTAssertEqual(data?.count, 21) + } + + func testParseAddressBech32m() { + // Valid testnet bech32m address + let address = "tdashevo1qz4242424242424242424242424242424g4dj6u7" + let data = AddressTransformer.parseAddress(address) + // Should return data if Bech32m decoder works, nil otherwise + XCTAssertTrue(data == nil || data?.count == 21) + } + + func testParseAddressInvalid() { + let data = AddressTransformer.parseAddress("invalid") + XCTAssertNil(data) + } + + func testFormatAddressAsHex() { + let data = Data([0x00, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, + 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78]) + let hex = AddressTransformer.formatAddress(data, asBech32m: false) + XCTAssertEqual(hex, "001234567890abcdef1234567890abcdef12345678") + } + + // MARK: - NumberTransformer Tests + + func testParseUInt64Valid() { + let value = NumberTransformer.parseUInt64("12345") + XCTAssertEqual(value, 12345) + } + + func testParseUInt64WithWhitespace() { + let value = NumberTransformer.parseUInt64(" 67890 ") + XCTAssertEqual(value, 67890) + } + + func testParseUInt64Invalid() { + let value = NumberTransformer.parseUInt64("not a number") + XCTAssertNil(value) + } + + func testParseUInt64Negative() { + let value = NumberTransformer.parseUInt64("-123") + XCTAssertNil(value) + } + + func testParseUInt64Zero() { + let value = NumberTransformer.parseUInt64("0") + XCTAssertEqual(value, 0) + } + + func testParseUInt32Valid() { + let value = NumberTransformer.parseUInt32("4294967295") + XCTAssertEqual(value, UInt32.max) + } + + func testParseUInt32Overflow() { + let value = NumberTransformer.parseUInt32("4294967296") + XCTAssertNil(value) + } + + func testParseAmountValid() { + let value = NumberTransformer.parseAmount("1000") + XCTAssertEqual(value, 1000) + } + + func testParseAmountZero() { + let value = NumberTransformer.parseAmount("0") + XCTAssertNil(value) + } + + func testParseAmountNegative() { + let value = NumberTransformer.parseAmount("-100") + XCTAssertNil(value) + } + + func testParseFeeValid() { + let value = NumberTransformer.parseFee("1") + XCTAssertEqual(value, 1) + } + + func testParseFeeInvalid() { + let value = NumberTransformer.parseFee("abc") + XCTAssertNil(value) + } + + func testFormatAmount() { + let formatted = NumberTransformer.formatAmount(1000000, unit: "credits") + XCTAssertTrue(formatted.contains("1,000,000") || formatted.contains("1000000")) + XCTAssertTrue(formatted.contains("credits")) + } + + func testFormatCreditsAsDash() { + // 100,000,000,000 credits = 1 Dash + let formatted = NumberTransformer.formatCreditsAsDash(100_000_000_000) + XCTAssertTrue(formatted.contains("1.00000000")) + XCTAssertTrue(formatted.contains("Dash")) + } + + func testFormatDuffsAsDash() { + // 100,000,000 duffs = 1 Dash + let formatted = NumberTransformer.formatDuffsAsDash(100_000_000) + XCTAssertTrue(formatted.contains("1.00000000")) + XCTAssertTrue(formatted.contains("Dash")) + } + + // MARK: - ResponseParser Tests + + func testParseAddressInfo() { + let addressBytes = Data(repeating: 0x00, count: 21) + let info = PlatformAddressInfo(addressBytes: addressBytes, nonce: 3, balance: 5000) + let parsed = ResponseParser.parseAddressInfo(info) + XCTAssertEqual(parsed.balance, 5000) + XCTAssertEqual(parsed.nonce, 3) + } + + func testFormatAddressInfo() { + let addressBytes = Data([0x00, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, + 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78]) + let info = PlatformAddressInfo(addressBytes: addressBytes, nonce: 1, balance: 1000) + let formatted = ResponseParser.formatAddressInfo(info) + XCTAssertTrue(formatted.contains("Address:")) + XCTAssertTrue(formatted.contains("Balance:")) + XCTAssertTrue(formatted.contains("Nonce:")) + } + + func testFormatAddressInfoWithoutAddress() { + let addressBytes = Data(repeating: 0x00, count: 21) + let info = PlatformAddressInfo(addressBytes: addressBytes, nonce: 5, balance: 2000) + let formatted = ResponseParser.formatAddressInfo(info, includeAddress: false) + XCTAssertFalse(formatted.contains("Address:")) + XCTAssertTrue(formatted.contains("Balance:")) + } + + // MARK: - TransferInputBuilder Tests + + func testCreateInputValid() { + let addressHex = "001234567890abcdef1234567890abcdef12345678" + let privateKeyHex = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + let input = TransferInputBuilder.createInput( + addressHex: addressHex, + amount: "1000", + nonce: 0, + privateKeyHex: privateKeyHex + ) + XCTAssertNotNil(input) + XCTAssertEqual(input?.amount, 1000) + XCTAssertEqual(input?.nonce, 0) + } + + func testCreateInputInvalidAddress() { + let input = TransferInputBuilder.createInput( + addressHex: "invalid", + amount: "1000", + privateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + ) + XCTAssertNil(input) + } + + func testCreateInputInvalidAmount() { + let addressHex = "001234567890abcdef1234567890abcdef12345678" + let privateKeyHex = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + let input = TransferInputBuilder.createInput( + addressHex: addressHex, + amount: "not a number", + privateKeyHex: privateKeyHex + ) + XCTAssertNil(input) + } + + func testCreateOutputValid() { + let addressHex = "001234567890abcdef1234567890abcdef12345678" + let output = TransferInputBuilder.createOutput( + addressHex: addressHex, + amount: "500" + ) + XCTAssertNotNil(output) + XCTAssertEqual(output?.amount, 500) + } + + func testCreateOutputInvalidAddress() { + let output = TransferInputBuilder.createOutput( + addressHex: "short", + amount: "500" + ) + XCTAssertNil(output) + } + + func testCreateOutputUniversalHex() { + let addressHex = "001234567890abcdef1234567890abcdef12345678" + let output = TransferInputBuilder.createOutputUniversal( + address: addressHex, + amount: "750" + ) + XCTAssertNotNil(output) + XCTAssertEqual(output?.amount, 750) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift new file mode 100644 index 00000000000..fff8bd68551 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyManagerTests.swift @@ -0,0 +1,222 @@ +import XCTest +@testable import SwiftDashSDK + +final class KeyManagerTests: XCTestCase { + + // MARK: - KeyFormatDetector Tests + + func testDetectFormatHex() { + // Valid 64-character hex string + let hex = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + XCTAssertEqual(KeyFormatDetector.detectFormat(hex), .hex) + } + + func testDetectFormatHexUppercase() { + let hex = "AAFF11223344556677889900AABBCCDDEEFF0011223344556677889900112233" + XCTAssertEqual(KeyFormatDetector.detectFormat(hex), .hex) + } + + func testDetectFormatHexMixedCase() { + let hex = "AaFf11223344556677889900aAbBcCdDeEfF0011223344556677889900112233" + XCTAssertEqual(KeyFormatDetector.detectFormat(hex), .hex) + } + + func testDetectFormatHexTooShort() { + let hex = "aaff1122334455" + XCTAssertEqual(KeyFormatDetector.detectFormat(hex), .unknown) + } + + func testDetectFormatHexTooLong() { + let hex = "aaff11223344556677889900aabbccddeeff00112233445566778899001122330011" + XCTAssertEqual(KeyFormatDetector.detectFormat(hex), .unknown) + } + + func testDetectFormatWIF() { + // Typical testnet WIF (starts with 'c') + let wif = "cNJFgo1DriFnPcBVKuXxyFbE46W4fPmqbMbjt7sWjrCmb3F1F8Vy" + XCTAssertEqual(KeyFormatDetector.detectFormat(wif), .wif) + } + + func testDetectFormatWIFMainnet() { + // Mainnet WIF (starts with '5') + let wif = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" + XCTAssertEqual(KeyFormatDetector.detectFormat(wif), .wif) + } + + func testDetectFormatEmpty() { + XCTAssertEqual(KeyFormatDetector.detectFormat(""), .unknown) + } + + func testDetectFormatWhitespace() { + XCTAssertEqual(KeyFormatDetector.detectFormat(" "), .unknown) + } + + func testDetectFormatUnknown() { + XCTAssertEqual(KeyFormatDetector.detectFormat("not-a-key"), .unknown) + } + + func testIsHexValid() { + XCTAssertTrue(KeyFormatDetector.isHex("0123456789abcdef")) + XCTAssertTrue(KeyFormatDetector.isHex("ABCDEF")) + XCTAssertTrue(KeyFormatDetector.isHex("")) + } + + func testIsHexInvalid() { + XCTAssertFalse(KeyFormatDetector.isHex("ghijkl")) + XCTAssertFalse(KeyFormatDetector.isHex("0x1234")) + XCTAssertFalse(KeyFormatDetector.isHex("12 34")) + } + + func testIsLikelyWIFValid() { + XCTAssertTrue(KeyFormatDetector.isLikelyWIF("5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ")) + XCTAssertTrue(KeyFormatDetector.isLikelyWIF("cNJFgo1DriFnPcBVKuXxyFbE46W4fPmqbMbjt7sWjrCmb3F1F8Vy")) + XCTAssertTrue(KeyFormatDetector.isLikelyWIF("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn")) + } + + func testIsLikelyWIFInvalid() { + XCTAssertFalse(KeyFormatDetector.isLikelyWIF("short")) + XCTAssertFalse(KeyFormatDetector.isLikelyWIF("AHueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ")) + } + + // MARK: - PrivateKeyParser Tests + + func testParseHexValid() { + let hex = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + let result = PrivateKeyParser.parse(hex) + XCTAssertTrue(result.isValid) + XCTAssertEqual(result.format, .hex) + XCTAssertEqual(result.data?.count, 32) + XCTAssertNil(result.error) + } + + func testParseHexWithWhitespace() { + let hex = " aaff11223344556677889900aabbccddeeff0011223344556677889900112233 " + let result = PrivateKeyParser.parse(hex) + XCTAssertTrue(result.isValid) + XCTAssertEqual(result.data?.count, 32) + } + + func testParseHexInvalidLength() { + let hex = "aaff1122" + let result = PrivateKeyParser.parseHex(hex) + XCTAssertFalse(result.isValid) + XCTAssertNotNil(result.error) + XCTAssertTrue(result.error?.contains("64 characters") ?? false) + } + + func testParseHexInvalidCharacters() { + let hex = "ggff11223344556677889900aabbccddeeff0011223344556677889900112233" + let result = PrivateKeyParser.parseHex(hex) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.error?.contains("Invalid hex") ?? false) + } + + func testParseEmpty() { + let result = PrivateKeyParser.parse("") + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.error?.contains("empty") ?? false) + } + + func testParseUnknownFormat() { + let result = PrivateKeyParser.parse("not-a-valid-key") + XCTAssertFalse(result.isValid) + XCTAssertEqual(result.format, .unknown) + } + + // MARK: - KeySizeValidator Tests + + func testExpectedPrivateKeySizeECDSA() { + XCTAssertEqual(KeySizeValidator.expectedPrivateKeySize(for: .ecdsaSecp256k1), 32) + } + + func testExpectedPrivateKeySizeBLS() { + XCTAssertEqual(KeySizeValidator.expectedPrivateKeySize(for: .bls12_381), 32) + } + + func testExpectedPrivateKeySizeEdDSA() { + XCTAssertEqual(KeySizeValidator.expectedPrivateKeySize(for: .eddsa25519Hash160), 32) + } + + func testIsValidSizeCorrect() { + let key = Data(repeating: 0x00, count: 32) + XCTAssertTrue(KeySizeValidator.isValidSize(key, for: .ecdsaSecp256k1)) + } + + func testIsValidSizeTooShort() { + let key = Data(repeating: 0x00, count: 16) + XCTAssertFalse(KeySizeValidator.isValidSize(key, for: .ecdsaSecp256k1)) + } + + func testIsValidSizeTooLong() { + let key = Data(repeating: 0x00, count: 64) + XCTAssertFalse(KeySizeValidator.isValidSize(key, for: .ecdsaSecp256k1)) + } + + // MARK: - KeyFormatter Tests + + func testToHex() { + let data = Data([0xaa, 0xbb, 0xcc, 0xdd]) + let hex = KeyFormatter.toHex(data) + XCTAssertEqual(hex, "aabbccdd") + } + + func testToHexEmpty() { + let data = Data() + let hex = KeyFormatter.toHex(data) + XCTAssertEqual(hex, "") + } + + func testToWIFAndBack() { + let originalKey = Data([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff + ]) + + // Encode to WIF + guard let wif = KeyFormatter.toWIF(originalKey, isTestnet: true) else { + XCTFail("Failed to encode to WIF") + return + } + + // Decode back + guard let decodedKey = WIFParser.parseWIF(wif) else { + XCTFail("Failed to decode WIF") + return + } + + XCTAssertEqual(originalKey, decodedKey) + } + + func testToWIFInvalidSize() { + let shortKey = Data(repeating: 0x00, count: 16) + XCTAssertNil(KeyFormatter.toWIF(shortKey)) + } + + // MARK: - KeyValidator Tests + + func testValidatePrivateKeyInvalidSize() { + let shortKey = Data(repeating: 0x00, count: 16) + let result = KeyValidator.validatePrivateKey(shortKey, against: []) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.error?.contains("32 bytes") ?? false) + } + + func testValidatePrivateKeyNoPublicKeys() { + let key = Data(repeating: 0x00, count: 32) + let result = KeyValidator.validatePrivateKey(key, against: []) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.error?.contains("No public keys") ?? false) + } + + func testValidatePrivateKeyInputEmpty() { + let result = KeyValidator.validatePrivateKeyInput("", against: []) + XCTAssertFalse(result.isValid) + } + + func testValidatePrivateKeyInputInvalidFormat() { + let result = KeyValidator.validatePrivateKeyInput("not-a-key", against: []) + XCTAssertFalse(result.isValid) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ValidationTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ValidationTests.swift new file mode 100644 index 00000000000..68f21874f6e --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ValidationTests.swift @@ -0,0 +1,403 @@ +import XCTest +@testable import SwiftDashSDK + +final class ValidationTests: XCTestCase { + + // MARK: - AddressValidator Tests + + func testValidateHexAddress_valid42CharHex() { + // 42 hex characters = 21 bytes (valid Platform address) + let validAddress = "00aaff11223344556677889900aabbccddeeff0011" + XCTAssertTrue(AddressValidator.validateHexAddress(validAddress)) + } + + func testValidateHexAddress_invalidLength() { + // Too short + XCTAssertFalse(AddressValidator.validateHexAddress("00aabb")) + // Too long + XCTAssertFalse(AddressValidator.validateHexAddress("00aaff11223344556677889900aabbccddeeff001122")) + // Empty + XCTAssertFalse(AddressValidator.validateHexAddress("")) + } + + func testValidateHexAddress_invalidCharacters() { + // Contains 'g' which is not valid hex + let invalidHex = "00aaff11223344556677889900aabbccddeeff00gg" + XCTAssertFalse(AddressValidator.validateHexAddress(invalidHex)) + // Contains spaces + XCTAssertFalse(AddressValidator.validateHexAddress("00 aabb")) + } + + func testValidatePrivateKey_valid64CharHex() { + // 64 hex characters = 32 bytes (valid private key) + let validKey = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + XCTAssertTrue(AddressValidator.validatePrivateKey(validKey)) + } + + func testValidatePrivateKey_invalidLength() { + // Too short + XCTAssertFalse(AddressValidator.validatePrivateKey("aabbccdd")) + // Too long + XCTAssertFalse(AddressValidator.validatePrivateKey("aaff11223344556677889900aabbccddeeff001122334455667788990011223355")) + // Empty + XCTAssertFalse(AddressValidator.validatePrivateKey("")) + } + + func testValidateHexString_customByteLength() { + // 36 bytes = 72 hex characters (outpoint) + let valid72 = String(repeating: "ab", count: 36) + XCTAssertTrue(AddressValidator.validateHexString(valid72, byteLength: 36)) + XCTAssertFalse(AddressValidator.validateHexString(valid72, byteLength: 32)) + } + + func testValidateAmount_validPositive() { + XCTAssertEqual(AddressValidator.validateAmount("100"), 100) + XCTAssertEqual(AddressValidator.validateAmount("1"), 1) + XCTAssertEqual(AddressValidator.validateAmount("18446744073709551615"), UInt64.max) // max UInt64 + XCTAssertEqual(AddressValidator.validateAmount(" 500 "), 500) // whitespace trimmed + } + + func testValidateAmount_invalid() { + XCTAssertNil(AddressValidator.validateAmount("0")) // zero not allowed + XCTAssertNil(AddressValidator.validateAmount("-100")) // negative + XCTAssertNil(AddressValidator.validateAmount("abc")) + XCTAssertNil(AddressValidator.validateAmount("")) + XCTAssertNil(AddressValidator.validateAmount("12.5")) // decimal + } + + func testValidateIdentityId_valid() { + XCTAssertTrue(AddressValidator.validateIdentityId("someIdentityId123")) + XCTAssertTrue(AddressValidator.validateIdentityId("aaff11223344556677889900aabbccddeeff0011223344556677889900112233")) + } + + func testValidateIdentityId_invalid() { + XCTAssertFalse(AddressValidator.validateIdentityId("")) + XCTAssertFalse(AddressValidator.validateIdentityId(" ")) + } + + func testValidateBech32mAddress_valid() { + // Valid testnet address (tdashevo1...) + // Using a well-formed bech32m address + let validTestnetAddress = "tdashevo1qz4242424242424242424242424242424g4dj6u7" + XCTAssertTrue(AddressValidator.validateBech32mAddress(validTestnetAddress)) + } + + func testValidateBech32mAddress_invalid() { + // Wrong HRP + XCTAssertFalse(AddressValidator.validateBech32mAddress("bitcoin1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")) + // No separator + XCTAssertFalse(AddressValidator.validateBech32mAddress("tdashevoqqqqqqqqqqq")) + // Empty + XCTAssertFalse(AddressValidator.validateBech32mAddress("")) + // Random string + XCTAssertFalse(AddressValidator.validateBech32mAddress("notanaddress")) + } + + func testValidateAddress_autoDetect() { + // Hex address + let hexAddr = "00aaff11223344556677889900aabbccddeeff0011" + XCTAssertTrue(AddressValidator.validateAddress(hexAddr)) + + // Bech32m address (if valid) + let bech32mAddr = "tdashevo1qz4242424242424242424242424242424g4dj6u7" + XCTAssertTrue(AddressValidator.validateAddress(bech32mAddr)) + + // Invalid + XCTAssertFalse(AddressValidator.validateAddress("invalid")) + } + + // MARK: - TransferInputValidator Tests + + func testTransferInputValidator_allValid() { + let result = TransferInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + outputAddressHex: "11bbcc22334455667788990011aabbccddeeff0022", + inputAmount: "1000", + outputAmount: "500" + ) + XCTAssertTrue(result.isValid) + XCTAssertTrue(result.errors.isEmpty) + } + + func testTransferInputValidator_invalidInputAddress() { + let result = TransferInputValidator.validate( + inputAddressHex: "invalid", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + outputAddressHex: "11bbcc22334455667788990011aabbccddeeff0022", + inputAmount: "1000", + outputAmount: "500" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Input address") }) + } + + func testTransferInputValidator_invalidPrivateKey() { + let result = TransferInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "tooshort", + outputAddressHex: "11bbcc22334455667788990011aabbccddeeff0022", + inputAmount: "1000", + outputAmount: "500" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Private key") }) + } + + func testTransferInputValidator_inputLessThanOutput() { + let result = TransferInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + outputAddressHex: "11bbcc22334455667788990011aabbccddeeff0022", + inputAmount: "500", + outputAmount: "1000" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Input amount must be greater") }) + } + + func testTransferInputValidator_multipleErrors() { + let result = TransferInputValidator.validate( + inputAddressHex: "bad", + inputPrivateKeyHex: "bad", + outputAddressHex: "bad", + inputAmount: "abc", + outputAmount: "xyz" + ) + XCTAssertFalse(result.isValid) + XCTAssertEqual(result.errors.count, 5) + } + + // MARK: - WithdrawInputValidator Tests + + func testWithdrawInputValidator_allValid() { + let result = WithdrawInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + coreAddress: "yXdMkHQZwWqtQNzCLXWPWrZvJT4RCJGxkp", + inputAmount: "1000", + useChangeAddress: false, + changeAddressHex: "" + ) + XCTAssertTrue(result.isValid) + XCTAssertTrue(result.errors.isEmpty) + } + + func testWithdrawInputValidator_withChangeAddress() { + let result = WithdrawInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + coreAddress: "yXdMkHQZwWqtQNzCLXWPWrZvJT4RCJGxkp", + inputAmount: "1000", + useChangeAddress: true, + changeAddressHex: "22ccdd33445566778899001122aabbccddeeff0033" + ) + XCTAssertTrue(result.isValid) + } + + func testWithdrawInputValidator_invalidChangeAddress() { + let result = WithdrawInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + coreAddress: "yXdMkHQZwWqtQNzCLXWPWrZvJT4RCJGxkp", + inputAmount: "1000", + useChangeAddress: true, + changeAddressHex: "invalid" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Change address") }) + } + + func testWithdrawInputValidator_emptyCoreAddress() { + let result = WithdrawInputValidator.validate( + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + coreAddress: "", + inputAmount: "1000", + useChangeAddress: false, + changeAddressHex: "" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Core address") }) + } + + // MARK: - TopUpAddressFromAssetLockValidator Tests + + func testTopUpValidator_validInstant() { + let result = TopUpAddressFromAssetLockValidator.validate( + outputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + assetLockPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + proofType: .instant, + instantLockHex: "someinstantlockdata", + transactionHex: "sometxdata", + outputIndex: "0", + coreChainLockedHeight: "", + outPointHex: "" + ) + XCTAssertTrue(result.isValid) + } + + func testTopUpValidator_validChain() { + let outpoint72 = String(repeating: "ab", count: 36) // 72 hex chars + let result = TopUpAddressFromAssetLockValidator.validate( + outputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + assetLockPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + proofType: .chain, + instantLockHex: "", + transactionHex: "", + outputIndex: "", + coreChainLockedHeight: "12345", + outPointHex: outpoint72 + ) + XCTAssertTrue(result.isValid) + } + + func testTopUpValidator_invalidOutpoint() { + let result = TopUpAddressFromAssetLockValidator.validate( + outputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + assetLockPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + proofType: .chain, + instantLockHex: "", + transactionHex: "", + outputIndex: "", + coreChainLockedHeight: "12345", + outPointHex: "tooshort" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Outpoint") }) + } + + // MARK: - IdentityTopUpFromAddressesValidator Tests + + func testIdentityTopUpValidator_valid() { + let result = IdentityTopUpFromAddressesValidator.validate( + identityId: "someIdentityId", + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + amount: "1000" + ) + XCTAssertTrue(result.isValid) + } + + func testIdentityTopUpValidator_emptyIdentityId() { + let result = IdentityTopUpFromAddressesValidator.validate( + identityId: "", + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + amount: "1000" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Identity ID") }) + } + + // MARK: - IdentityTransferToAddressesValidator Tests + + func testIdentityTransferValidator_valid() { + let result = IdentityTransferToAddressesValidator.validate( + identityId: "someIdentityId", + outputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + identityPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + amount: "1000" + ) + XCTAssertTrue(result.isValid) + } + + // MARK: - IdentityCreateFromAddressesValidator Tests + + func testIdentityCreateValidator_valid() { + let result = IdentityCreateFromAddressesValidator.validate( + identityId: "someIdentityId", + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + identityPrivateKeyHex: "bbff11223344556677889900aabbccddeeff0011223344556677889900112233", + amount: "1000", + nonce: "0", + useChangeAddress: false, + changeAddressHex: "" + ) + XCTAssertTrue(result.isValid) + } + + func testIdentityCreateValidator_invalidNonce() { + let result = IdentityCreateFromAddressesValidator.validate( + identityId: "someIdentityId", + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + identityPrivateKeyHex: "bbff11223344556677889900aabbccddeeff0011223344556677889900112233", + amount: "1000", + nonce: "notanumber", + useChangeAddress: false, + changeAddressHex: "" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Nonce") }) + } + + func testIdentityCreateValidator_invalidChangeAddress() { + let result = IdentityCreateFromAddressesValidator.validate( + identityId: "someIdentityId", + inputAddressHex: "00aaff11223344556677889900aabbccddeeff0011", + inputPrivateKeyHex: "aaff11223344556677889900aabbccddeeff0011223344556677889900112233", + identityPrivateKeyHex: "bbff11223344556677889900aabbccddeeff0011223344556677889900112233", + amount: "1000", + nonce: "0", + useChangeAddress: true, + changeAddressHex: "invalid" + ) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.errors.contains { $0.contains("Change address") }) + } + + // MARK: - ValidationResult Tests + + func testValidationResult_valid() { + let result = ValidationResult.valid() + XCTAssertTrue(result.isValid) + XCTAssertTrue(result.errors.isEmpty) + } + + func testValidationResult_invalid() { + let result = ValidationResult.invalid(["Error 1", "Error 2"]) + XCTAssertFalse(result.isValid) + XCTAssertEqual(result.errors.count, 2) + } + + func testValidationResult_invalidWithEmptyErrors() { + // When errors is empty, isValid should be true + let result = ValidationResult.invalid([]) + XCTAssertTrue(result.isValid) + } + + // MARK: - Hash Validation Tests + + func testValidateHash_valid64CharHex() { + let validHash = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + XCTAssertTrue(AddressValidator.validateHash(validHash)) + } + + func testValidateHash_invalidLength() { + XCTAssertFalse(AddressValidator.validateHash("aabbccdd")) + XCTAssertFalse(AddressValidator.validateHash("")) + } + + // MARK: - Identity ID Hex Validation Tests + + func testValidateIdentityIdHex_valid() { + let validId = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + XCTAssertTrue(AddressValidator.validateIdentityIdHex(validId)) + } + + func testIsHexIdentityId_valid() { + let validId = "aaff11223344556677889900aabbccddeeff0011223344556677889900112233" + XCTAssertTrue(AddressValidator.isHexIdentityId(validId)) + } + + func testIsHexIdentityId_invalid() { + // Too short (base58 style) + XCTAssertFalse(AddressValidator.isHexIdentityId("4EfA9Wam5vEXTbYbP8YFZP35o9F")) + // Empty + XCTAssertFalse(AddressValidator.isHexIdentityId("")) + // Invalid chars + XCTAssertFalse(AddressValidator.isHexIdentityId("ggff11223344556677889900aabbccddeeff0011223344556677889900112233")) + } +}