diff --git a/.vscode/launch.json b/.vscode/launch.json index 89724ee5..50c21740 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,64 +1,76 @@ { - "configurations": [ - { - "type": "lldb", - "request": "launch", - "sourceLanguages": [ - "swift" - ], - "args": [], - "cwd": "${workspaceFolder:boka}/Boka", - "name": "Debug Boka (Boka)", - "program": "${workspaceFolder:boka}/Boka/.build/debug/Boka", - "preLaunchTask": "swift: Build Debug Boka (Boka)" - }, - { - "type": "lldb", - "request": "launch", - "sourceLanguages": [ - "swift" - ], - "args": [], - "cwd": "${workspaceFolder:boka}/Boka", - "name": "Release Boka (Boka)", - "program": "${workspaceFolder:boka}/Boka/.build/release/Boka", - "preLaunchTask": "swift: Build Release Boka (Boka)" - }, - { - "type": "lldb", - "request": "launch", - "args": [], - "cwd": "${workspaceFolder:boka}/Cli", - "name": "Debug Cli (Cli)", - "program": "${workspaceFolder:boka}/Cli/.build/debug/Cli", - "preLaunchTask": "swift: Build Debug Cli (Cli)" - }, - { - "type": "lldb", - "request": "launch", - "args": [], - "cwd": "${workspaceFolder:boka}/Cli", - "name": "Release Cli (Cli)", - "program": "${workspaceFolder:boka}/Cli/.build/release/Cli", - "preLaunchTask": "swift: Build Release Cli (Cli)" - }, - { - "type": "lldb", - "request": "launch", - "args": [], - "cwd": "${workspaceFolder:boka}/Tools", - "name": "Debug Tools (Tools)", - "program": "${workspaceFolder:boka}/Tools/.build/debug/Tools", - "preLaunchTask": "swift: Build Debug Tools (Tools)" - }, - { - "type": "lldb", - "request": "launch", - "args": [], - "cwd": "${workspaceFolder:boka}/Tools", - "name": "Release Tools (Tools)", - "program": "${workspaceFolder:boka}/Tools/.build/release/Tools", - "preLaunchTask": "swift: Build Release Tools (Tools)" - } - ] -} + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Boka", + "name": "Debug Boka (Boka)", + "program": "${workspaceFolder:boka}/Boka/.build/debug/Boka", + "preLaunchTask": "swift: Build Debug Boka (Boka)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Boka", + "name": "Release Boka (Boka)", + "program": "${workspaceFolder:boka}/Boka/.build/release/Boka", + "preLaunchTask": "swift: Build Release Boka (Boka)" + }, + { + "type": "lldb", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Cli", + "name": "Debug Cli (Cli)", + "program": "${workspaceFolder:boka}/Cli/.build/debug/Cli", + "preLaunchTask": "swift: Build Debug Cli (Cli)" + }, + { + "type": "lldb", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Cli", + "name": "Release Cli (Cli)", + "program": "${workspaceFolder:boka}/Cli/.build/release/Cli", + "preLaunchTask": "swift: Build Release Cli (Cli)" + }, + { + "type": "lldb", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Tools", + "name": "Debug Tools (Tools)", + "program": "${workspaceFolder:boka}/Tools/.build/debug/Tools", + "preLaunchTask": "swift: Build Debug Tools (Tools)" + }, + { + "type": "lldb", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Tools", + "name": "Release Tools (Tools)", + "program": "${workspaceFolder:boka}/Tools/.build/release/Tools", + "preLaunchTask": "swift: Build Release Tools (Tools)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Fuzzing", + "name": "Debug BokaFuzzer (Fuzzing)", + "program": "${workspaceFolder:boka}/Fuzzing/.build/debug/BokaFuzzer", + "preLaunchTask": "swift: Build Debug BokaFuzzer (Fuzzing)" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:boka}/Fuzzing", + "name": "Release BokaFuzzer (Fuzzing)", + "program": "${workspaceFolder:boka}/Fuzzing/.build/release/BokaFuzzer", + "preLaunchTask": "swift: Build Release BokaFuzzer (Fuzzing)" + } + ] +} \ No newline at end of file diff --git a/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift b/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift index fec89005..906f8cef 100644 --- a/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift +++ b/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift @@ -114,7 +114,7 @@ extension BlockchainDataProvider { try await dataProvider.getFinalizedHead() } - public func getKeys(prefix: Data31, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { + public func getKeys(prefix: Data, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { try await dataProvider.getKeys(prefix: prefix, count: count, startKey: startKey, blockHash: blockHash) } diff --git a/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProviderProtocol.swift b/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProviderProtocol.swift index 90db05bf..f8c4be8e 100644 --- a/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProviderProtocol.swift +++ b/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProviderProtocol.swift @@ -20,7 +20,7 @@ public protocol BlockchainDataProviderProtocol: Sendable { func getHeads() async throws -> Set - func getKeys(prefix: Data31, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] + func getKeys(prefix: Data, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] func getStorage(key: Data31, blockHash: Data32?) async throws -> [String] diff --git a/Blockchain/Sources/Blockchain/BlockchainDataProvider/InMemoryDataProvider.swift b/Blockchain/Sources/Blockchain/BlockchainDataProvider/InMemoryDataProvider.swift index 6d5b3d4a..1c2c0e01 100644 --- a/Blockchain/Sources/Blockchain/BlockchainDataProvider/InMemoryDataProvider.swift +++ b/Blockchain/Sources/Blockchain/BlockchainDataProvider/InMemoryDataProvider.swift @@ -35,7 +35,7 @@ extension InMemoryDataProvider: BlockchainDataProviderProtocol { guaranteedWorkReports[guaranteedWorkReport.value.workReport.hash()] = guaranteedWorkReport } - public func getKeys(prefix: Data31, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { + public func getKeys(prefix: Data, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { guard let stateRef = try getState(hash: blockHash ?? genesisBlockHash) else { return [] } diff --git a/Blockchain/Sources/Blockchain/State/InMemoryBackend.swift b/Blockchain/Sources/Blockchain/State/InMemoryBackend.swift index af5dadce..1c7e15cb 100644 --- a/Blockchain/Sources/Blockchain/State/InMemoryBackend.swift +++ b/Blockchain/Sources/Blockchain/State/InMemoryBackend.swift @@ -35,6 +35,11 @@ public actor InMemoryBackend: StateBackendProtocol { public func readAll(prefix: Data, startKey: Data?, limit: UInt32?) async throws -> [(key: Data, value: Data)] { var resp = [(key: Data, value: Data)]() + + if let limit { + resp.reserveCapacity(Int(limit)) + } + let startKey = startKey ?? prefix let startIndex = store.insertIndex(KVPair(key: startKey, value: Data())) for i in startIndex ..< store.array.count { @@ -104,4 +109,27 @@ public actor InMemoryBackend: StateBackendProtocol { logger.info("ref count: \(refCount)") } } + + public func createIterator(prefix: Data, startKey: Data?) async throws -> StateBackendIterator { + InMemoryStateIterator(store: store, prefix: prefix, startKey: startKey) + } +} + +public final class InMemoryStateIterator: StateBackendIterator, @unchecked Sendable { + private var iterator: Array<(key: Data, value: Data)>.Iterator + + init(store: SortedArray, prefix: Data, startKey: Data?) { + let searchKey = startKey ?? prefix + let startIndex = store.insertIndex(InMemoryBackend.KVPair(key: searchKey, value: Data())) + + let matchingItems = Array(store.array[startIndex...].prefix { item in + item.key.starts(with: prefix) + }.map { (key: $0.key, value: $0.value) }) + + iterator = matchingItems.makeIterator() + } + + public func next() async throws -> (key: Data, value: Data)? { + iterator.next() + } } diff --git a/Blockchain/Sources/Blockchain/State/StateBackend.swift b/Blockchain/Sources/Blockchain/State/StateBackend.swift index 0660c242..383a7ce9 100644 --- a/Blockchain/Sources/Blockchain/State/StateBackend.swift +++ b/Blockchain/Sources/Blockchain/State/StateBackend.swift @@ -38,8 +38,50 @@ public final class StateBackend: Sendable { throw StateBackendError.missingState(key: key) } - public func getKeys(_ prefix: Data31, _ startKey: Data31?, _ limit: UInt32?) async throws -> [(key: Data, value: Data)] { - try await impl.readAll(prefix: prefix.data, startKey: startKey?.data, limit: limit) + public func getKeys(_ prefix: Data?, _ startKey: Data31?, _ limit: UInt32?) async throws -> [(key: Data, value: Data)] { + let prefixData = prefix ?? Data() + let startKeyData = startKey?.data + + let iterator = try await impl.createIterator(prefix: Data(), startKey: startKeyData) + + var stateKeyValues: [(key: Data, value: Data)] = [] + + if let limit { + stateKeyValues.reserveCapacity(Int(limit)) + } + + while let (_, trieNodeData) = try await iterator.next() { + if let limit, stateKeyValues.count >= limit { + break + } + + guard trieNodeData.count == 64 else { + continue + } + + let firstByte = trieNodeData[relative: 0] + let isLeaf = (firstByte & 0b1100_0000) == 0b1000_0000 || (firstByte & 0b1100_0000) == 0b1100_0000 + + guard isLeaf else { + continue + } + + let stateKey = Data(trieNodeData[relative: 1 ..< 32]) + + if !prefixData.isEmpty, !stateKey.starts(with: prefixData) { + continue + } + + if let startKeyData, stateKey.lexicographicallyPrecedes(startKeyData) { + continue + } + + if let value = try await trie.read(key: Data31(stateKey)!) { + stateKeyValues.append((key: stateKey, value: value)) + } + } + + return stateKeyValues } public func batchRead(_ keys: [any StateKey]) async throws -> [(key: any StateKey, value: (Codable & Sendable)?)] { diff --git a/Blockchain/Sources/Blockchain/State/StateBackendProtocol.swift b/Blockchain/Sources/Blockchain/State/StateBackendProtocol.swift index 5c377dca..29c66405 100644 --- a/Blockchain/Sources/Blockchain/State/StateBackendProtocol.swift +++ b/Blockchain/Sources/Blockchain/State/StateBackendProtocol.swift @@ -8,6 +8,10 @@ public enum StateBackendOperation: Sendable { case refDecrement(key: Data) } +public protocol StateBackendIterator: Sendable { + func next() async throws -> (key: Data, value: Data)? +} + /// key: trie node hash (31 bytes) /// value: trie node data (64 bytes) /// ref counting requirements: @@ -20,6 +24,7 @@ public enum StateBackendOperation: Sendable { public protocol StateBackendProtocol: Sendable { func read(key: Data) async throws -> Data? func readAll(prefix: Data, startKey: Data?, limit: UInt32?) async throws -> [(key: Data, value: Data)] + func createIterator(prefix: Data, startKey: Data?) async throws -> StateBackendIterator func batchUpdate(_ ops: [StateBackendOperation]) async throws // hash is the blake2b256 hash of the value diff --git a/Boka/Package.swift b/Boka/Package.swift index aa880397..33bfb513 100644 --- a/Boka/Package.swift +++ b/Boka/Package.swift @@ -11,6 +11,7 @@ let package = Package( dependencies: [ .package(path: "../Node"), .package(path: "../TracingUtils"), + .package(path: "../Fuzzing"), .package(url: "https://github.com/slashmo/swift-otel.git", from: "0.9.0"), .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.0"), .package(url: "https://github.com/vapor/console-kit.git", from: "4.15.0"), @@ -25,6 +26,7 @@ let package = Package( dependencies: [ "Node", "TracingUtils", + "Fuzzing", .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "OTel", package: "swift-otel"), .product(name: "OTLPGRPC", package: "swift-otel"), diff --git a/Boka/Sources/Boka.swift b/Boka/Sources/Boka.swift index 2ecdf394..cf50a979 100644 --- a/Boka/Sources/Boka.swift +++ b/Boka/Sources/Boka.swift @@ -52,7 +52,7 @@ struct Boka: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "JAM built with Swift", version: "0.0.1", - subcommands: [Generate.self] + subcommands: [Generate.self, Fuzz.self] ) @Option(name: .shortAndLong, help: "Base path to database files.") diff --git a/Boka/Sources/Fuzz.swift b/Boka/Sources/Fuzz.swift new file mode 100644 index 00000000..08ed364c --- /dev/null +++ b/Boka/Sources/Fuzz.swift @@ -0,0 +1,85 @@ +import ArgumentParser +import Foundation +import Fuzzing +import Logging + +extension Boka { + struct Fuzz: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "fuzz", + abstract: "JAM Conformance Protocol", + subcommands: [Target.self, Fuzzer.self] + ) + } +} + +extension Boka.Fuzz { + enum JamConfig: String, CaseIterable, ExpressibleByArgument { + case tiny + case full + } + + struct Target: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run fuzzing target - waits for fuzzer connections" + ) + + @Option(help: "Unix socket path for fuzzing protocol") + var socketPath: String = "/tmp/jam_conformance.sock" + + @Option(help: "JAM Protocol configuration preset") + var config: JamConfig = .tiny + + func run() async throws { + let env = ProcessInfo.processInfo.environment + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = parseLevel(env["LOG_LEVEL"] ?? "") ?? .info + return handler + } + + let fuzzTarget = try FuzzingTarget( + socketPath: socketPath, + config: config.rawValue + ) + + try await fuzzTarget.run() + } + } + + struct Fuzzer: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run fuzzing fuzzer - connects to targets" + ) + + @Option(help: "Unix socket path for fuzzing protocol.") + var socketPath: String = "/tmp/jam_conformance.sock" + + @Option(help: "JAM Protocol configuration preset.") + var config: JamConfig = .tiny + + @Option(name: .long, help: "Random seed for deterministic testing. Default is random") + var seed: UInt64 = .random(in: 0 ... UInt64.max) + + @Option(name: .long, help: "Number of blocks to process.") + var blocks: Int = 100 + + func run() async throws { + let env = ProcessInfo.processInfo.environment + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = parseLevel(env["LOG_LEVEL"] ?? "") ?? .info + return handler + } + + let fuzzer = try FuzzingClient( + socketPath: socketPath, + config: config.rawValue, + seed: seed, + blockCount: blocks + ) + + try await fuzzer.run() + } + } +} diff --git a/Boka/Sources/Tracing.swift b/Boka/Sources/Tracing.swift index 861a3917..b2ae2e53 100644 --- a/Boka/Sources/Tracing.swift +++ b/Boka/Sources/Tracing.swift @@ -40,7 +40,7 @@ public func parse(from: String) -> ( ) } -private func parseLevel(_ level: String) -> Logger.Level? { +public func parseLevel(_ level: String) -> Logger.Level? { switch level.lowercased().trimmingCharacters(in: .whitespaces) { case "trace": .trace case "debug": .debug diff --git a/Database/Sources/Database/RocksDBBackend.swift b/Database/Sources/Database/RocksDBBackend.swift index ebd45bd6..a944e255 100644 --- a/Database/Sources/Database/RocksDBBackend.swift +++ b/Database/Sources/Database/RocksDBBackend.swift @@ -86,7 +86,7 @@ extension RocksDBBackend: BlockchainDataProviderProtocol { try guaranteedWorkReports.put(key: hash, value: guaranteedWorkReport) } - public func getKeys(prefix: Data31, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { + public func getKeys(prefix: Data, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { logger.trace(""" getKeys() prefix: \(prefix), count: \(count), startKey: \(String(describing: startKey)), blockHash: \(String(describing: blockHash)) @@ -305,4 +305,50 @@ extension RocksDBBackend: StateBackendProtocol { // TODO: implement } + + public func createIterator(prefix: Data, startKey: Data?) async throws -> StateBackendIterator { + RocksDBStateIterator(db: db, prefix: prefix, startKey: startKey) + } +} + +public final class RocksDBStateIterator: StateBackendIterator, @unchecked Sendable { + private let iterator: Iterator + private let prefix: Data + private var isFirstRead = true + private let triePrefix = Data([0]) + + init(db: RocksDB, prefix: Data, startKey: Data?) { + let snapshot = db.createSnapshot() + let readOptions = ReadOptions() + readOptions.setSnapshot(snapshot) + + iterator = db.createIterator(column: .state, readOptions: readOptions) + self.prefix = triePrefix + prefix + + if let startKey { + iterator.seek(to: triePrefix + startKey) + } else if !prefix.isEmpty { + iterator.seek(to: triePrefix + prefix) + } else { + iterator.seek(to: triePrefix) + } + } + + public func next() async throws -> (key: Data, value: Data)? { + if !isFirstRead { + iterator.next() + } + isFirstRead = false + + guard let (key, value) = iterator.read() else { + return nil + } + + guard key.starts(with: prefix) else { + return nil + } + + let stateKey = Data(key.dropFirst(triePrefix.count)) + return (stateKey, value) + } } diff --git a/Fuzzing/Package.swift b/Fuzzing/Package.swift new file mode 100644 index 00000000..9cee599d --- /dev/null +++ b/Fuzzing/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Fuzzing", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "Fuzzing", + targets: ["Fuzzing"] + ), + ], + dependencies: [ + .package(path: "../Blockchain"), + .package(path: "../Codec"), + .package(path: "../TracingUtils"), + .package(path: "../Utils"), + .package(url: "https://github.com/apple/swift-testing.git", branch: "6.0.0"), + ], + targets: [ + .target( + name: "Fuzzing", + dependencies: [ + "Blockchain", + "Codec", + "TracingUtils", + "Utils", + ], + swiftSettings: [ + .interoperabilityMode(.Cxx), + ] + ), + .testTarget( + name: "FuzzingTests", + dependencies: [ + "Fuzzing", + .product(name: "Testing", package: "swift-testing"), + ], + swiftSettings: [ + .interoperabilityMode(.Cxx), + ] + ), + ], + swiftLanguageModes: [.version("6")] +) diff --git a/Fuzzing/Sources/Fuzzing/FuzzGenerator/FuzzGenerator.swift b/Fuzzing/Sources/Fuzzing/FuzzGenerator/FuzzGenerator.swift new file mode 100644 index 00000000..cce3802e --- /dev/null +++ b/Fuzzing/Sources/Fuzzing/FuzzGenerator/FuzzGenerator.swift @@ -0,0 +1,29 @@ +import Blockchain +import Codec +import Foundation +import Utils + +/// Protocol for generating fuzzing test data +public protocol FuzzGenerator { + /// Generate initial state for fuzzing + /// - Parameters: + /// - timeslot: The timeslot for which to generate the state + /// - config: Protocol configuration + /// - Returns: Array of fuzz key-value pairs representing the state + func generateState(timeslot: TimeslotIndex, config: ProtocolConfigRef) async throws -> [FuzzKeyValue] + + /// Generate a block for the given timeslot and state + /// - Parameters: + /// - timeslot: The timeslot for which to generate the block + /// - currentStateRef: Current state reference + /// - config: Protocol configuration + /// - Returns: A valid block reference + func generateBlock(timeslot: UInt32, currentStateRef: StateRef, config: ProtocolConfigRef) async throws -> BlockRef +} + +/// Error types for fuzz generators +public enum FuzzGeneratorError: Error { + case stateGenerationFailed(String) + case blockGenerationFailed(String) + case invalidTestData(String) +} diff --git a/Fuzzing/Sources/Fuzzing/FuzzGenerator/FuzzGeneratorRandom.swift b/Fuzzing/Sources/Fuzzing/FuzzGenerator/FuzzGeneratorRandom.swift new file mode 100644 index 00000000..eae86ee0 --- /dev/null +++ b/Fuzzing/Sources/Fuzzing/FuzzGenerator/FuzzGeneratorRandom.swift @@ -0,0 +1,60 @@ +import Blockchain +import Codec +import Foundation +import TracingUtils +import Utils + +private let logger = Logger(label: "FuzzGeneratorRandom") + +public class SeededRandomNumberGenerator: RandomNumberGenerator { + private var state: UInt64 + + public init(seed: UInt64) { + state = seed + } + + public func next() -> UInt64 { + // Linear Congruential Generator formula: next = (a * current + c) mod m + // Uses the same parameters as the classic Borland C/C++ rand() function + state = state &* 1_103_515_245 &+ 12345 + return state + } + + public func randomInt(_ range: ClosedRange) -> Int { + let randomValue = next() + let fraction = Double(randomValue % UInt64(Int32.max)) / Double(Int32.max) + return range.lowerBound + Int(fraction * Double(range.count)) + } +} + +/// Random fuzzing generator that creates pseudo-random state and blocks +public class FuzzGeneratorRandom: FuzzGenerator { + private let seed: UInt64 + private var generator: SeededRandomNumberGenerator + private let config: ProtocolConfigRef + + private var blockAuthor: BlockAuthor? + private var scheduler: MockScheduler? + private var keystore: KeyStore? + private var dataProvider: BlockchainDataProvider? + + public init(seed: UInt64, config: ProtocolConfigRef) { + self.seed = seed + self.config = config + generator = SeededRandomNumberGenerator(seed: seed) + blockAuthor = nil + scheduler = nil + keystore = nil + dataProvider = nil + } + + // TODO: Implement state generation logic + public func generateState(timeslot _: TimeslotIndex, config _: ProtocolConfigRef) async throws -> [FuzzKeyValue] { + fatalError("not implemented") + } + + // TODO: Implement block generation logic + public func generateBlock(timeslot _: UInt32, currentStateRef _: StateRef, config _: ProtocolConfigRef) async throws -> BlockRef { + fatalError("not implemented") + } +} diff --git a/Fuzzing/Sources/Fuzzing/FuzzingClient.swift b/Fuzzing/Sources/Fuzzing/FuzzingClient.swift new file mode 100644 index 00000000..d43c3701 --- /dev/null +++ b/Fuzzing/Sources/Fuzzing/FuzzingClient.swift @@ -0,0 +1,233 @@ +import Blockchain +import Codec +import Foundation +import TracingUtils +import Utils + +private let logger = Logger(label: "FuzzingClient") + +public class FuzzingClient { + public enum FuzzingClientError: Error { + case invalidConfig + case connectionFailed + case handshakeFailed + case targetNotResponding + case stateNotSet + } + + private let socket: FuzzingSocket + private var connection: FuzzingSocketConnection? + private let fuzzGenerator: any FuzzGenerator + private let config: ProtocolConfigRef + private let devKey: KeySet + private let blockCount: Int + private let runtime: Runtime + private var currentStateRef: StateRef? + + public init( + socketPath: String, + config: String, + seed: UInt64, + blockCount: Int, + ) throws { + switch config { + case "tiny": + self.config = ProtocolConfigRef.tiny + case "full": + self.config = ProtocolConfigRef.mainnet + default: + logger.error("Invalid config: \(config). Only 'tiny' or 'full' allowed.") + throw FuzzingClientError.invalidConfig + } + + socket = FuzzingSocket(socketPath: socketPath, config: self.config) + + fuzzGenerator = FuzzGeneratorRandom(seed: seed, config: self.config) + + devKey = try DevKeyStore.getDevKey(seed: UInt32(seed % UInt64(UInt32.max))) + self.blockCount = blockCount + runtime = Runtime(config: self.config) + currentStateRef = nil + + logger.info("Boka fuzzer initialized with socket: \(socketPath), seed: \(seed), blockCount: \(blockCount)") + } + + public func run() async throws { + logger.info("🚀 Starting Boka Fuzzer") + + try connect() + try handshake() + try await runFuzzingSessions() + + logger.info("🎯 Fuzzing completed successfully!") + + disconnect() + } + + public func connect() throws { + connection = try socket.connect() + logger.info("🔌 Connected to fuzzing target") + } + + public func disconnect() { + connection?.close() + connection = nil + logger.info("🔌 Disconnected from fuzzing target") + } + + public func handshake() throws { + guard let connection else { + throw FuzzingClientError.connectionFailed + } + + let message = FuzzingMessage.peerInfo(.init(name: "boka-fuzzing-fuzzer")) + try connection.sendMessage(message) + + if let response = try connection.receiveMessage(), case let .peerInfo(info) = response { + logger.info("🤝 Handshake completed with \(info.name), app version: \(info.appVersion), jam version: \(info.jamVersion)") + } else { + throw FuzzingClientError.targetNotResponding + } + } + + public func runFuzzingSessions() async throws { + guard let connection else { + throw FuzzingClientError.connectionFailed + } + + for blockIndex in 0 ..< blockCount { + let timeslot = UInt32(blockIndex + 1) + logger.info("📦 Processing block \(blockIndex + 1)/\(blockCount) for timeslot \(timeslot)") + + // generate state + let kv = try await fuzzGenerator.generateState(timeslot: timeslot, config: config) + + // set state locally + let rawKV = kv.map { (key: $0.key, value: $0.value) } + let backend = StateBackend(InMemoryBackend(), config: config, rootHash: Data32()) + try await backend.writeRaw(rawKV) + let state = try await State(backend: backend) + currentStateRef = state.asRef() + + // set state on target + try await setState(kv: kv, connection: connection) + + // generate a block + let blockRef = try await fuzzGenerator.generateBlock( + timeslot: timeslot, + currentStateRef: currentStateRef!, + config: config + ) + + // import block locally + currentStateRef = try await runtime.apply(block: blockRef.toValidated(config: config), state: currentStateRef!) + let currentStateRoot = await currentStateRef!.value.stateRoot + + // import block on target + let targetStateRoot = try importBlock(block: blockRef.value, connection: connection) + + if currentStateRoot == targetStateRoot { + logger.info("✅ State roots match for block \(blockIndex + 1)!") + } else { + logger.error("❌ STATE ROOT MISMATCH for block \(blockIndex + 1):") + logger.error(" Fuzzer: \(currentStateRoot.data.toHexString())") + logger.error(" Target: \(targetStateRoot.data.toHexString())") + + let targetState = try getState(connection: connection) + + try await generateMismatchReport( + blockIndex: blockIndex + 1, + targetState: targetState, + localStateRef: currentStateRef! + ) + + break + } + } + + logger.info("🎯 Fuzzing session completed - processed \(blockCount) blocks") + } + + private func setState(kv: [FuzzKeyValue], connection: FuzzingSocketConnection) async throws { + logger.info("🏗️ SET STATE") + + let dummyHeader = Header.dummy(config: config) + let setStateMessage = FuzzingMessage.setState(FuzzSetState(header: dummyHeader, state: kv)) + try connection.sendMessage(setStateMessage) + + if let response = try connection.receiveMessage(), case let .stateRoot(root) = response { + logger.info("🏗️ SET STATE success, root: \(root.data.toHexString())") + } else { + throw FuzzingClientError.targetNotResponding + } + } + + private func importBlock(block: Block, connection: FuzzingSocketConnection) throws -> Data32 { + logger.info("📦 IMPORT BLOCK") + + let importMessage = FuzzingMessage.importBlock(block) + try connection.sendMessage(importMessage) + + if let response = try connection.receiveMessage(), case let .stateRoot(root) = response { + logger.info("📦 IMPORT BLOCK success, new state root: \(root.data.toHexString())") + return root + } else { + throw FuzzingClientError.targetNotResponding + } + } + + private func getState(connection: FuzzingSocketConnection) throws -> [FuzzKeyValue] { + logger.info("🔍 GET STATE") + + let getStateMessage = FuzzingMessage.getState(Data32()) + try connection.sendMessage(getStateMessage) + + if let response = try connection.receiveMessage(), case let .state(keyValues) = response { + logger.info("📋 GET STATE success: \(keyValues.count) key-value pairs") + return keyValues + } else { + throw FuzzingClientError.targetNotResponding + } + } + + private func generateMismatchReport( + blockIndex: Int, + targetState: [FuzzKeyValue], + localStateRef: StateRef, + ) async throws { + logger.info("📊 Generating mismatch report for block \(blockIndex)") + + let keyValuePairs = try await localStateRef.value.backend.getKeys(nil, nil, nil) + let localState: [FuzzKeyValue] = keyValuePairs.map { FuzzKeyValue(key: Data31($0.key)!, value: $0.value) } + + let targetMap = Dictionary(uniqueKeysWithValues: targetState.map { (kv: FuzzKeyValue) in + (kv.key.data.toHexString(), kv.value.toHexString()) + }) + let localMap = Dictionary(uniqueKeysWithValues: localState.map { (kv: FuzzKeyValue) in + (kv.key.data.toHexString(), kv.value.toHexString()) + }) + + let allKeys = Set(targetMap.keys).union(Set(localMap.keys)) + var differences: [String] = [] + + for key in allKeys.sorted() { + let targetValue = targetMap[key] + let localValue = localMap[key] + + if targetValue != localValue { + let targetStr = targetValue ?? "" + let localStr = localValue ?? "" + differences.append("Key \(key):\n Target: \(targetStr)\n Local: \(localStr)") + } + } + + logger.error("📊 STATE DIFF REPORT (Block \(blockIndex)):") + logger.error(" Total differences: \(differences.count)") + logger.error(" Target state keys: \(targetMap.count)") + logger.error(" Local state keys: \(localMap.count)") + + for diff in differences { + logger.error(" \(diff)") + } + } +} diff --git a/Fuzzing/Sources/Fuzzing/FuzzingSocket.swift b/Fuzzing/Sources/Fuzzing/FuzzingSocket.swift new file mode 100644 index 00000000..ad995888 --- /dev/null +++ b/Fuzzing/Sources/Fuzzing/FuzzingSocket.swift @@ -0,0 +1,232 @@ +import Blockchain +import Codec +import Foundation +import TracingUtils + +#if canImport(Darwin) + import Darwin + + private let platformClose = Darwin.close + private let platformConnect = Darwin.connect + private let platformSockStream = SOCK_STREAM +#elseif canImport(Glibc) + import Glibc + + private let platformClose = Glibc.close + private let platformConnect = Glibc.connect + private let platformSockStream = Int32(SOCK_STREAM.rawValue) +#else + #error("Unsupported platform") +#endif + +private let logger = Logger(label: "FuzzingSocket") + +public enum FuzzingSocketError: Error { + case socketCreationFailed + case socketBindFailed + case socketListenFailed + case socketConnectFailed + case acceptFailed + case receiveFailed + case sendFailed + case invalidMessageSize +} + +public class FuzzingSocket { + private let socketPath: String + private var socketFd: Int32 = -1 + private let config: ProtocolConfigRef + + public init(socketPath: String, config: ProtocolConfigRef) { + self.socketPath = socketPath + self.config = config + } + + deinit { + if socketFd >= 0 { + _ = platformClose(socketFd) + socketFd = -1 + } + unlink(socketPath) + } + + /// Create a Unix domain socket and bind it to the specified path + public func create() throws { + // Create Unix domain socket + socketFd = socket(AF_UNIX, platformSockStream, 0) + + guard socketFd >= 0 else { + throw FuzzingSocketError.socketCreationFailed + } + + // Remove existing socket file if present + unlink(socketPath) + + // Bind socket + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + var sunPath = addr.sun_path + withUnsafeMutablePointer(to: &sunPath) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: addr.sun_path)) { cPtr in + _ = strcpy(cPtr, socketPath) + } + } + addr.sun_path = sunPath + + let bindResult = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(socketFd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult == 0 else { + _ = platformClose(socketFd) + throw FuzzingSocketError.socketBindFailed + } + + // Listen for connections + guard listen(socketFd, 1) == 0 else { + _ = platformClose(socketFd) + throw FuzzingSocketError.socketListenFailed + } + } + + /// Accept a client connection + public func acceptConnection() throws -> FuzzingSocketConnection { + guard socketFd >= 0 else { + throw FuzzingSocketError.socketCreationFailed + } + + let clientFd = accept(socketFd, nil, nil) + guard clientFd >= 0 else { + throw FuzzingSocketError.acceptFailed + } + + return FuzzingSocketConnection(fd: clientFd, config: config) + } + + /// Connect to socket + public func connect() throws -> FuzzingSocketConnection { + socketFd = socket(AF_UNIX, platformSockStream, 0) + + guard socketFd >= 0 else { + throw FuzzingSocketError.socketCreationFailed + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + var sunPath = addr.sun_path + withUnsafeMutablePointer(to: &sunPath) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: addr.sun_path)) { cPtr in + _ = strcpy(cPtr, socketPath) + } + } + addr.sun_path = sunPath + + let result = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + platformConnect(socketFd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + guard result == 0 else { + _ = platformClose(socketFd) + throw FuzzingSocketError.socketConnectFailed + } + + return FuzzingSocketConnection(fd: socketFd, config: config) + } +} + +/// Represents an active socket connection for message exchange +public class FuzzingSocketConnection { + private let fd: Int32 + private let config: ProtocolConfigRef + + init(fd: Int32, config: ProtocolConfigRef) { + self.fd = fd + self.config = config + } + + deinit { + self.close() + } + + public func receiveMessage() throws -> FuzzingMessage? { + // Read message length (4 bytes, little endian) + var lengthBuffer = [UInt8](repeating: 0, count: 4) + var totalRead = 0 + + // Handle partial reads for length + while totalRead < 4 { + let bytesRead = recv(fd, &lengthBuffer[totalRead], 4 - totalRead, 0) + if bytesRead == 0 { + if totalRead == 0 { + return nil // Clean connection close + } else { + logger.error("Connection closed unexpectedly mid-read (got \(totalRead)/4 bytes)") + throw FuzzingSocketError.receiveFailed + } + } + if bytesRead < 0 { + logger.error("Error receiving length: \(String(cString: strerror(errno)))") + throw FuzzingSocketError.receiveFailed + } + totalRead += bytesRead + } + + // Parse message length (little endian) + let messageLength = lengthBuffer.withUnsafeBytes { $0.load(as: UInt32.self) } + + // Sanity check message length + guard messageLength > 0, messageLength < 1024 * 1024 else { + throw FuzzingSocketError.invalidMessageSize + } + + // Read message data + var messageBuffer = [UInt8](repeating: 0, count: Int(messageLength)) + totalRead = 0 + + // Handle partial reads for message data + while totalRead < messageLength { + let bytesRead = recv(fd, &messageBuffer[totalRead], Int(messageLength) - totalRead, 0) + if bytesRead <= 0 { + logger.error("Error receiving message data: \(String(cString: strerror(errno)))") + throw FuzzingSocketError.receiveFailed + } + totalRead += bytesRead + } + + let data = Data(messageBuffer) + return try JamDecoder.decode(FuzzingMessage.self, from: data, withConfig: config) + } + + public func sendMessage(_ message: FuzzingMessage) throws { + let data = try JamEncoder.encode(message) + let length = UInt32(data.count) + + // Create complete message buffer (length + data) + var completeBuffer = Data() + completeBuffer.append(contentsOf: withUnsafeBytes(of: length) { Data($0) }) + completeBuffer.append(data) + + // Try to send complete message in one system call + let messageArray = Array(completeBuffer) + let totalSize = completeBuffer.count + + let bytesSent = messageArray.withUnsafeBufferPointer { ptr in + send(fd, ptr.baseAddress!, totalSize, 0) + } + + if bytesSent != totalSize { + let error = bytesSent < 0 ? String(cString: strerror(errno)) : "Partial send (\(bytesSent)/\(totalSize))" + logger.error("Failed to send complete message: \(error)") + throw FuzzingSocketError.sendFailed + } + } + + public func close() { + guard fd >= 0 else { return } + _ = platformClose(fd) + } +} diff --git a/Fuzzing/Sources/Fuzzing/FuzzingTarget.swift b/Fuzzing/Sources/Fuzzing/FuzzingTarget.swift new file mode 100644 index 00000000..4eeba234 --- /dev/null +++ b/Fuzzing/Sources/Fuzzing/FuzzingTarget.swift @@ -0,0 +1,158 @@ +import Blockchain +import Codec +import Foundation +import TracingUtils +import Utils + +private let logger = Logger(label: "FuzzingTarget") + +public class FuzzingTarget { + public enum FuzzingTargetError: Error { + case invalidConfig + case stateNotSet + } + + private let socket: FuzzingSocket + private let runtime: Runtime + private let config: ProtocolConfigRef + private var currentStateRef: StateRef? + + public init(socketPath: String, config: String) throws { + switch config { + case "tiny": + self.config = ProtocolConfigRef.tiny + case "full": + self.config = ProtocolConfigRef.mainnet + default: + logger.error("Invalid config: \(config). Only 'tiny' or 'full' allowed.") + throw FuzzingTargetError.invalidConfig + } + + runtime = Runtime(config: self.config) + currentStateRef = nil + socket = FuzzingSocket(socketPath: socketPath, config: self.config) + + logger.info("Boka Fuzzing Target initialized on socket \(socketPath)") + } + + public func run() async throws { + try socket.create() + + let connection = try socket.acceptConnection() + + try await handleFuzzer(connection: connection) + + connection.close() + logger.info("Connection closed") + } + + private func handleFuzzer(connection: FuzzingSocketConnection) async throws { + logger.info("New fuzzer connected") + + var messageCount = 0 + while true { + guard let message = try connection.receiveMessage() else { break } + messageCount += 1 + logger.info("✉️ Message #\(messageCount)") + try await handleMessage(message: message, connection: connection) + } + } + + private func handleMessage(message: FuzzingMessage, connection: FuzzingSocketConnection) async throws { + switch message { + case let .peerInfo(peerInfo): + try await handleHandShake(peerInfo: peerInfo, connection: connection) + + case let .importBlock(block): + try await handleImportBlock(block: block, connection: connection) + + case let .setState(setState): + try await handleSetState(setState: setState, connection: connection) + + case let .getState(headerHash): + try await handleGetState(headerHash: headerHash, connection: connection) + + case .state, .stateRoot: + logger.warning("Received response message (ignored)") + } + } + + private func handleHandShake(peerInfo: FuzzPeerInfo, connection: FuzzingSocketConnection) async throws { + logger.info("Handshake from: \(peerInfo.name), App Version: \(peerInfo.appVersion), Jam Version: \(peerInfo.jamVersion)") + let message = FuzzingMessage.peerInfo(FuzzPeerInfo(name: "boka-fuzzing-target")) + try connection.sendMessage(message) + logger.info("Handshake completed") + } + + private func handleImportBlock(block: Block, connection: FuzzingSocketConnection) async throws { + logger.info("IMPORT BLOCK: \(block.header.hash().description)") + logger.info("Block number: \(block.header.timeslot)") + + do { + guard let stateRef = currentStateRef else { throw FuzzingTargetError.stateNotSet } + + let blockRef = try block.asRef().toValidated(config: config) + let newStateRef = try await runtime.apply(block: blockRef, state: stateRef) + + currentStateRef = newStateRef + + logger.info("IMPORT BLOCK completed") + let stateRoot = await currentStateRef?.value.stateRoot ?? Data32() + let response = FuzzingMessage.stateRoot(stateRoot) + try connection.sendMessage(response) + } catch { + logger.error("❌ Failed to import block: \(error)") + let stateRoot = await currentStateRef?.value.stateRoot ?? Data32() + let response = FuzzingMessage.stateRoot(stateRoot) + try connection.sendMessage(response) + } + } + + private func handleSetState(setState: FuzzSetState, connection: FuzzingSocketConnection) async throws { + logger.info("SET STATE: \(setState.state.count) key-value pairs") + + do { + // set state + let rawKV = setState.state.map { (key: $0.key, value: $0.value) } + let backend = StateBackend(InMemoryBackend(), config: config, rootHash: Data32()) + try await backend.writeRaw(rawKV) + let state = try await State(backend: backend) + let stateRef = state.asRef() + + // check state root + let root = await stateRef.value.stateRoot + logger.info("State root: \(root)") + + currentStateRef = stateRef + + logger.info("SET STATE completed") + let response = FuzzingMessage.stateRoot(root) + try connection.sendMessage(response) + } catch { + logger.error("❌ Failed to set state: \(error)") + let stateRoot = await currentStateRef?.value.stateRoot ?? Data32() + let response = FuzzingMessage.stateRoot(stateRoot) + try connection.sendMessage(response) + } + } + + private func handleGetState(headerHash: FuzzGetState, connection: FuzzingSocketConnection) async throws { + logger.info("GET STATE request for header: \(headerHash)") + + do { + guard let currentStateRef else { throw FuzzingTargetError.stateNotSet } + + // Get all key-value pairs from the state backend + let keyValuePairs = try await currentStateRef.value.backend.getKeys(nil, nil, nil) + let fuzzKeyValues: [FuzzKeyValue] = keyValuePairs.map { FuzzKeyValue(key: Data31($0.key)!, value: $0.value) } + + logger.info("GET STATE completed: \(fuzzKeyValues.count) key-value pairs found") + let response = FuzzingMessage.state(fuzzKeyValues) + try connection.sendMessage(response) + } catch { + logger.error("❌ Failed to get state: \(error)") + let response = FuzzingMessage.state([]) + try connection.sendMessage(response) + } + } +} diff --git a/Fuzzing/Sources/Fuzzing/Messages.swift b/Fuzzing/Sources/Fuzzing/Messages.swift new file mode 100644 index 00000000..cf90c863 --- /dev/null +++ b/Fuzzing/Sources/Fuzzing/Messages.swift @@ -0,0 +1,126 @@ +import Blockchain +import Codec +import Foundation +import Utils + +public struct FuzzVersion: Codable { + public let major: UInt8 + public let minor: UInt8 + public let patch: UInt8 + + public init(major: UInt8, minor: UInt8, patch: UInt8) { + self.major = major + self.minor = minor + self.patch = patch + } + + private enum CodingKeys: String, CodingKey { + case major, minor, patch + } +} + +extension FuzzVersion: CustomStringConvertible { + public var description: String { + "\(major).\(minor).\(patch)" + } +} + +public struct FuzzPeerInfo: Codable { + public let name: String + public let appVersion: FuzzVersion + public let jamVersion: FuzzVersion + + public init( + name: String, + appVersion: FuzzVersion = FuzzVersion(major: 1, minor: 0, patch: 0), + jamVersion: FuzzVersion = FuzzVersion(major: 0, minor: 6, patch: 6) + ) { + self.name = name + self.appVersion = appVersion + self.jamVersion = jamVersion + } +} + +public struct FuzzKeyValue: Codable { + public let key: Data31 + public let value: Data + + public init(key: Data31, value: Data) { + self.key = key + self.value = value + } +} + +public typealias FuzzState = [FuzzKeyValue] + +public struct FuzzSetState: Codable { + public let header: Header + public let state: FuzzState + + public init(header: Header, state: FuzzState) { + self.header = header + self.state = state + } +} + +public typealias FuzzGetState = Data32 // HeaderHash +public typealias FuzzStateRoot = Data32 // StateRootHash + +public enum FuzzingMessage: Codable { + case peerInfo(FuzzPeerInfo) + case importBlock(Block) + case setState(FuzzSetState) + case getState(FuzzGetState) + case state(FuzzState) + case stateRoot(FuzzStateRoot) + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + let variant = try container.decode(UInt8.self) + switch variant { + case 0: + self = try .peerInfo(container.decode(FuzzPeerInfo.self)) + case 1: + self = try .importBlock(container.decode(Block.self)) + case 2: + self = try .setState(container.decode(FuzzSetState.self)) + case 3: + self = try .getState(container.decode(FuzzGetState.self)) + case 4: + self = try .state(container.decode(FuzzState.self)) + case 5: + self = try .stateRoot(container.decode(FuzzStateRoot.self)) + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid FuzzingMessage variant: \(variant)" + ) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + switch self { + case let .peerInfo(value): + try container.encode(UInt8(0)) + try container.encode(value) + case let .importBlock(value): + try container.encode(UInt8(1)) + try container.encode(value) + case let .setState(value): + try container.encode(UInt8(2)) + try container.encode(value) + case let .getState(value): + try container.encode(UInt8(3)) + try container.encode(value) + case let .state(value): + try container.encode(UInt8(4)) + try container.encode(value) + case let .stateRoot(value): + try container.encode(UInt8(5)) + try container.encode(value) + } + } +} diff --git a/Fuzzing/Tests/FuzzingTests/FuzzingTests.swift b/Fuzzing/Tests/FuzzingTests/FuzzingTests.swift new file mode 100644 index 00000000..b7aec481 --- /dev/null +++ b/Fuzzing/Tests/FuzzingTests/FuzzingTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import Fuzzing + +struct FuzzingTests { + @Test func testSeededRandomNumberGenerator() throws { + let seed: UInt64 = 42 + let generator1 = SeededRandomNumberGenerator(seed: seed) + let generator2 = SeededRandomNumberGenerator(seed: seed) + + let value1a = generator1.next() + let value1b = generator1.next() + + let value2a = generator2.next() + let value2b = generator2.next() + + #expect(value1a == value2a) + #expect(value1b == value2b) + #expect(value1a != value1b) + + let range = 1 ... 10 + let randomInt1 = generator1.randomInt(range) + let randomInt2 = generator1.randomInt(range) + + #expect(range.contains(randomInt1)) + #expect(range.contains(randomInt2)) + + let generator3 = SeededRandomNumberGenerator(seed: 999) + let value3 = generator3.next() + #expect(value3 != value1a) + } +} diff --git a/Node/Sources/Node/NodeDataSource.swift b/Node/Sources/Node/NodeDataSource.swift index 1d3449ca..d7db3c35 100644 --- a/Node/Sources/Node/NodeDataSource.swift +++ b/Node/Sources/Node/NodeDataSource.swift @@ -119,7 +119,7 @@ extension NodeDataSource: KeystoreDataSource { } extension NodeDataSource: ChainDataSource { - public func getKeys(prefix: Data31, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { + public func getKeys(prefix: Data, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] { try await chainDataProvider.getKeys(prefix: prefix, count: count, startKey: startKey, blockHash: blockHash) } diff --git a/Node/Tests/NodeTests/NodeTests.swift b/Node/Tests/NodeTests/NodeTests.swift index 9c80db79..842957bb 100644 --- a/Node/Tests/NodeTests/NodeTests.swift +++ b/Node/Tests/NodeTests/NodeTests.swift @@ -81,7 +81,7 @@ final class NodeTests { // Verify block was produced #expect(newTimeslot > initialTimeslot) #expect(try await validatorNode.blockchain.dataProvider.hasBlock(hash: newBestHead.hash)) - #expect(try await validatorNode.blockchain.dataProvider.getKeys(prefix: Data31(), count: 0, startKey: nil, blockHash: nil).isEmpty) + #expect(try await validatorNode.blockchain.dataProvider.getKeys(prefix: Data(), count: 0, startKey: nil, blockHash: nil).isEmpty) await #expect(throws: StateBackendError.self) { _ = try await validatorNode.blockchain.dataProvider.getStorage(key: Data31.random(), blockHash: nil) } diff --git a/Node/Tests/NodeTests/StateBackendTests.swift b/Node/Tests/NodeTests/StateBackendTests.swift new file mode 100644 index 00000000..50cc59fd --- /dev/null +++ b/Node/Tests/NodeTests/StateBackendTests.swift @@ -0,0 +1,172 @@ +import Blockchain +import Database +import Foundation +import Testing +import Utils + +@testable import Node + +enum BackendType: String, CaseIterable { + case inMemory = "InMemoryBackend" + case rocksDB = "RocksDBBackend" +} + +final class StateBackendTests { + let basePath = { + let tmpDir = FileManager.default.temporaryDirectory + return tmpDir.appendingPathComponent("\(UUID().uuidString)") + }() + + let config: ProtocolConfigRef = .dev + let genesisBlock: BlockRef + + init() async throws { + genesisBlock = BlockRef.dummy(config: config) + } + + deinit { + try? FileManager.default.removeItem(at: basePath) + } + + func createBackend(_ backendType: BackendType, testIndex: Int = 0) async throws -> StateBackendProtocol { + switch backendType { + case .inMemory: + return InMemoryBackend() + case .rocksDB: + let testPath = basePath.appendingPathComponent("test_\(testIndex)") + return try await RocksDBBackend( + path: testPath, + config: config, + genesisBlock: genesisBlock, + genesisStateData: [:] + ) + } + } + + @Test(arguments: BackendType.allCases) + func testGetKeysBasic(backendType: BackendType) async throws { + let backend = try await createBackend(backendType, testIndex: 1) + let stateBackend = StateBackend(backend, config: config, rootHash: Data32()) + + let prefixA = Data([0xAA]) + let prefixB = Data([0xBB]) + + let testPairs: [(Data31, Data)] = [ + (Data31(prefixA + Data([0x01]) + Data(repeating: 0, count: 29))!, Data("valueA1".utf8)), + (Data31(prefixA + Data([0x02]) + Data(repeating: 0, count: 29))!, Data("valueA2".utf8)), + (Data31(prefixA + Data([0x03]) + Data(repeating: 0, count: 29))!, Data("valueA3".utf8)), + + (Data31(prefixB + Data([0x01]) + Data(repeating: 0, count: 29))!, Data("valueB1".utf8)), + (Data31(prefixB + Data([0x02]) + Data(repeating: 0, count: 29))!, Data("valueB2".utf8)), + + (Data31(Data([0x01]) + Data(repeating: 0, count: 30))!, Data("value1".utf8)), + (Data31(Data([0x02]) + Data(repeating: 0, count: 30))!, Data("value2".utf8)), + ] + + // Write test data + for (key, value) in testPairs { + try await stateBackend.writeRaw([(key: key, value: value)]) + } + + // Test: Get all keys + let allKeys = try await stateBackend.getKeys(nil, nil, nil) + #expect(allKeys.count == testPairs.count, "[\(backendType.rawValue)] Should return all \(testPairs.count) keys") + + for (expectedKey, expectedValue) in testPairs { + let found = allKeys.first { $0.key == expectedKey.data } + #expect(found != nil, "[\(backendType.rawValue)] Key \(expectedKey.toHexString()) should be found") + #expect(found?.value == expectedValue, "[\(backendType.rawValue)] Value should match for key \(expectedKey.toHexString())") + } + + // Test: Prefix filtering + let prefixAResults = try await stateBackend.getKeys(prefixA, nil, nil) + #expect(prefixAResults.count == 3, "[\(backendType.rawValue)] Should find 3 keys with prefix A") + + for result in prefixAResults { + #expect(result.key.starts(with: prefixA), "[\(backendType.rawValue)] All results should have prefix A") + } + + let prefixBResults = try await stateBackend.getKeys(prefixB, nil, nil) + #expect(prefixBResults.count == 2, "[\(backendType.rawValue)] Should find 2 keys with prefix B") + + for result in prefixBResults { + #expect(result.key.starts(with: prefixB), "[\(backendType.rawValue)] All results should have prefix B") + } + + // Test: Start key filtering + let startKey = Data31(Data([0x05]) + Data(repeating: 0, count: 30))! + let startKeyResults = try await stateBackend.getKeys(nil, startKey, nil) + + // Should find keys >= 0x05, which are the 0xAA and 0xBB prefixed keys (5 total) + #expect( + startKeyResults.count == 5, + "[\(backendType.rawValue)] Should get exactly 5 keys starting from 0x05 (AA and BB prefixed keys)" + ) + + for result in startKeyResults { + let isSmaller = result.key.lexicographicallyPrecedes(startKey.data) + #expect(!isSmaller, "[\(backendType.rawValue)] All keys should be >= start key (0x05)") + } + + // Test: Limit + let limitedKeys = try await stateBackend.getKeys(nil, nil, 3) + #expect(limitedKeys.count == 3, "[\(backendType.rawValue)] Should respect the limit of 3") + + // Test: Combined prefix and limit + let prefixLimitedResults = try await stateBackend.getKeys(prefixA, nil, 2) + #expect(prefixLimitedResults.count == 2, "[\(backendType.rawValue)] Should respect both prefix and limit") + + for result in prefixLimitedResults { + #expect(result.key.starts(with: prefixA), "[\(backendType.rawValue)] All results should have prefix A") + } + } + + @Test(arguments: BackendType.allCases) + func testGetKeysLargeBatch(backendType: BackendType) async throws { + let backend = try await createBackend(backendType, testIndex: 2) + let stateBackend = StateBackend(backend, config: config, rootHash: Data32()) + + let keyCount = 1500 + var expectedKeys: Set = [] + + // Use a specific prefix to avoid any potential conflicts + let batchPrefix = Data([0xFF]) + + for i in 0 ..< keyCount { + var keyData = Data(repeating: 0, count: 31) + keyData[0] = batchPrefix[0] + keyData[1] = UInt8(i % 256) + keyData[2] = UInt8(i / 256) + + let key = Data31(keyData)! + let value = Data("batchValue\(i)".utf8) + + try await stateBackend.writeRaw([(key: key, value: value)]) + expectedKeys.insert(key.data) + } + + // Test: Get all keys with the batch prefix + let results = try await stateBackend.getKeys(batchPrefix, nil, nil) + let resultKeys = Set(results.map(\.key)) + + #expect(results.count == keyCount, "[\(backendType.rawValue)] Should return all \(keyCount) batch keys") + #expect(resultKeys == expectedKeys, "[\(backendType.rawValue)] Should return exactly the expected batch keys") + + // Test: Large batch with limit + let limitedResults = try await stateBackend.getKeys(batchPrefix, nil, 100) + #expect(limitedResults.count == 100, "[\(backendType.rawValue)] Should respect limit of 100 for large batch") + + // Test: Large batch with startKey + let midKey = Data31(Data([0xFF, 128, 0]) + Data(repeating: 0, count: 28))! + let startKeyResults = try await stateBackend.getKeys(batchPrefix, midKey, nil) + + // Should get roughly half the results (keys >= midKey) + #expect(startKeyResults.count > 0, "[\(backendType.rawValue)] Should get some results with startKey in large batch") + #expect(startKeyResults.count < keyCount, "[\(backendType.rawValue)] Should get less than total with startKey filter") + + for result in startKeyResults { + let isSmaller = result.key.lexicographicallyPrecedes(midKey.data) + #expect(!isSmaller, "[\(backendType.rawValue)] All results should be >= startKey in large batch") + } + } +} diff --git a/RPC/Sources/RPC/DataSource/DataSource.swift b/RPC/Sources/RPC/DataSource/DataSource.swift index 44df2fd5..42114e95 100644 --- a/RPC/Sources/RPC/DataSource/DataSource.swift +++ b/RPC/Sources/RPC/DataSource/DataSource.swift @@ -18,7 +18,7 @@ public protocol ChainDataSource: Sendable { func getBlockHash(byTimeslot timeslot: TimeslotIndex) async throws -> Set func getHeader(hash: Data32) async throws -> HeaderRef? func getFinalizedHead() async throws -> Data32? - func getKeys(prefix: Data31, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] + func getKeys(prefix: Data, count: UInt32, startKey: Data31?, blockHash: Data32?) async throws -> [String] func getStorage(key: Data31, blockHash: Data32?) async throws -> [String] } diff --git a/RPC/Sources/RPC/Handlers/StateHandlers.swift b/RPC/Sources/RPC/Handlers/StateHandlers.swift index 351c605d..809dd942 100644 --- a/RPC/Sources/RPC/Handlers/StateHandlers.swift +++ b/RPC/Sources/RPC/Handlers/StateHandlers.swift @@ -16,7 +16,7 @@ public enum StateHandlers { } public struct GetKeys: RPCHandler { - public typealias Request = Request4 + public typealias Request = Request4 public typealias Response = [String] public static var method: String { "state_getKeys" } diff --git a/RPC/Tests/RPCTests/ChainHandlesTests.swift b/RPC/Tests/RPCTests/ChainHandlesTests.swift index e7c79988..bbf6f299 100644 --- a/RPC/Tests/RPCTests/ChainHandlesTests.swift +++ b/RPC/Tests/RPCTests/ChainHandlesTests.swift @@ -16,7 +16,7 @@ public final class DummyNodeDataSource: Sendable { } extension DummyNodeDataSource: ChainDataSource { - public func getKeys(prefix _: Data31, count _: UInt32, startKey _: Data31?, blockHash _: Data32?) async throws + public func getKeys(prefix _: Data, count _: UInt32, startKey _: Data31?, blockHash _: Data32?) async throws -> [String] { ["key1", "key2", "key3"] diff --git a/TracingUtils/Sources/TracingUtils/logger.swift b/TracingUtils/Sources/TracingUtils/logger.swift index 5227f6ee..88fdaef0 100644 --- a/TracingUtils/Sources/TracingUtils/logger.swift +++ b/TracingUtils/Sources/TracingUtils/logger.swift @@ -3,7 +3,7 @@ import Foundation enum TestLogger { static let setupOnce: () = { LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardError(label: label) + var handler = StreamLogHandler.standardOutput(label: label) handler.logLevel = .trace return handler } diff --git a/boka.xcodeproj/project.pbxproj b/boka.xcodeproj/project.pbxproj index b7ef868c..527fc7d6 100644 --- a/boka.xcodeproj/project.pbxproj +++ b/boka.xcodeproj/project.pbxproj @@ -19,12 +19,14 @@ 06F2335F2C0B306000A5E2E0 /* Database */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Database; sourceTree = ""; }; 06F233602C0C69F100A5E2E0 /* Utils */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Utils; sourceTree = ""; }; 5A48C0302CA2D8FA000927F7 /* Networking */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Networking; sourceTree = ""; }; + 9524A5A92E2628F100881791 /* Fuzzing */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Fuzzing; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ 066C92652C095CE5005DDE4F = { isa = PBXGroup; children = ( + 9524A5A92E2628F100881791 /* Fuzzing */, 06814C5E2CF869B6007CCEC2 /* Tools */, 06E2B78F2C7304FF00E35A48 /* Codec */, 064D330E2C632ACC001A5F36 /* RPC */,