diff --git a/Blockchain/Sources/Blockchain/Blockchain.swift b/Blockchain/Sources/Blockchain/Blockchain.swift index 813ac9e2..0892785a 100644 --- a/Blockchain/Sources/Blockchain/Blockchain.swift +++ b/Blockchain/Sources/Blockchain/Blockchain.swift @@ -5,7 +5,7 @@ import Utils /// Holds the state of the blockchain. /// Includes the canonical chain as well as pending forks. /// Assume all blocks and states are valid and have been validated. -public class Blockchain { +public final class Blockchain: Sendable { public let config: ProtocolConfigRef private let dataProvider: BlockchainDataProvider @@ -30,4 +30,15 @@ public class Blockchain { // TODO: purge forks try await dataProvider.setFinalizedHead(hash: hash) } + + public func getBestBlock() async throws -> BlockRef { + guard let hash = try await dataProvider.getHeads().first else { + try throwUnreachable("no head") + } + return try await dataProvider.getBlock(hash: hash) + } + + public func getBlock(hash: Data32) async throws -> BlockRef? { + try await dataProvider.getBlock(hash: hash) + } } diff --git a/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift b/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift index 0a849aaf..cc767f4d 100644 --- a/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift +++ b/Blockchain/Sources/Blockchain/BlockchainDataProvider/BlockchainDataProvider.swift @@ -4,7 +4,7 @@ public enum BlockchainDataProviderError: Error { case unknownHash } -public protocol BlockchainDataProvider { +public protocol BlockchainDataProvider: Sendable { func hasHeader(hash: Data32) async throws -> Bool func isHead(hash: Data32) async throws -> Bool diff --git a/Boka/Package.resolved b/Boka/Package.resolved index 06ae0b6e..46a844e8 100644 --- a/Boka/Package.resolved +++ b/Boka/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "b09a7b54cb34c5b40b9c30546e215da07c64f3935c5d0c0b0daca60d07584231", + "originHash" : "8311dd4d27e8fd2abbee30df498eef1b06b76ecc7d9e7ff836f014d67d6698c7", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "0ae99db85b2b9d1e79b362bd31fd1ffe492f7c47", + "version" : "1.21.2" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", + "version" : "1.20.0" + } + }, { "identity" : "blake2.swift", "kind" : "remoteSourceControl", @@ -10,6 +28,15 @@ "version" : "0.2.0" } }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "9f7932f22ab6f64aafadc14491e694179b7d0f6f", + "version" : "4.14.3" + } + }, { "identity" : "grpc-swift", "kind" : "remoteSourceControl", @@ -19,6 +46,24 @@ "version" : "1.23.0" } }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "a31236f24bfd2ea2f520a74575881f6731d7ae68", + "version" : "4.7.0" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "8c9a227476555c55837e569be71944e02a056b72", + "version" : "4.9.1" + } + }, { "identity" : "scalecodec.swift", "kind" : "remoteSourceControl", @@ -28,6 +73,15 @@ "revision" : "dac3e7161de34c60c82794d031de0231b5a5746e" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.0" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -154,6 +208,15 @@ "version" : "1.21.0" } }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + }, { "identity" : "swift-otel", "kind" : "remoteSourceControl", @@ -207,6 +270,24 @@ "revision" : "4d2cf7c64443cdf4df833d0bedd767bf9dbc49d9", "version" : "0.1.3" } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "a823735db57b46100b0c61cdfc5a08525b1e7cad", + "version" : "4.102.1" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "4232d34efa49f633ba61afde365d3896fc7f8740", + "version" : "2.15.0" + } } ], "version" : 3 diff --git a/Boka/Package.swift b/Boka/Package.swift index 1cdd988c..d07e00b0 100644 --- a/Boka/Package.swift +++ b/Boka/Package.swift @@ -14,6 +14,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser", from: "1.4.0"), .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.14.3"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -27,6 +28,7 @@ let package = Package( .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "OTel", package: "swift-otel"), .product(name: "OTLPGRPC", package: "swift-otel"), + .product(name: "ConsoleKit", package: "console-kit"), ] ), .testTarget( diff --git a/Boka/Sources/Boka.swift b/Boka/Sources/Boka.swift index aea94e87..188f3095 100644 --- a/Boka/Sources/Boka.swift +++ b/Boka/Sources/Boka.swift @@ -13,12 +13,24 @@ import TracingUtils struct Boka: AsyncParsableCommand { mutating func run() async throws { let services = try await Tracing.bootstrap("Boka") - let node = try await Node(genesis: .dev, config: .dev) + let logger = Logger(label: "boka") + + logger.info("Starting Boka...") + + let config = Node.Config(rpc: RPCConfig(listenAddress: "127.0.0.1", port: 9955), protocol: .dev) + var node: Node! = try await Node(genesis: .dev, config: config) node.sayHello() - let config = ServiceGroupConfiguration(services: services, logger: Logger(label: "boka")) - let serviceGroup = ServiceGroup(configuration: config) + for service in services { + Task { + try await service.run() + } + } + + try await node.wait() + + node = nil - try await serviceGroup.run() + logger.info("Exiting...") } } diff --git a/Boka/Sources/Tracing.swift b/Boka/Sources/Tracing.swift index 43e165e7..da4514cc 100644 --- a/Boka/Sources/Tracing.swift +++ b/Boka/Sources/Tracing.swift @@ -1,3 +1,4 @@ +import ConsoleKit import OTel import OTLPGRPC import ServiceLifecycle @@ -6,11 +7,12 @@ import TracingUtils public enum Tracing { public static func bootstrap(_ serviceName: String) async throws -> [Service] { // Bootstrap the logging backend with the OTel metadata provider which includes span IDs in logging messages. - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardError(label: label, metadataProvider: .otel) - handler.logLevel = .trace - return handler - } + LoggingSystem.bootstrap( + fragment: timestampDefaultLoggerFragment(), + console: Terminal(), + level: .trace, + metadataProvider: .otel + ) // Configure OTel resource detection to automatically apply helpful attributes to events. let environment = OTelEnvironment.detected() diff --git a/Makefile b/Makefile index 8635761b..544b9d98 100644 --- a/Makefile +++ b/Makefile @@ -47,3 +47,7 @@ lint: githooks .PHONY: format format: githooks swiftformat . + +.PHONY: run +run: githooks + swift run --package-path Boka diff --git a/Node/Package.resolved b/Node/Package.resolved index 7dcbaed3..a65ae019 100644 --- a/Node/Package.resolved +++ b/Node/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "9029b5282681161e046bf609299ad38f9148736fe1e0db6b71446a8b1ce5274b", + "originHash" : "9e44dd111684519469ea1d4307ee132a892ac71a51c358d02a5b1ff1d5323474", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "0ae99db85b2b9d1e79b362bd31fd1ffe492f7c47", + "version" : "1.21.2" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31", + "version" : "1.20.0" + } + }, { "identity" : "blake2.swift", "kind" : "remoteSourceControl", @@ -11,12 +29,30 @@ } }, { - "identity" : "grpc-swift", + "identity" : "console-kit", "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift.git", + "location" : "https://github.com/vapor/console-kit.git", "state" : { - "revision" : "6a90b7e77e29f9bda6c2b3a4165a40d6c02cfda1", - "version" : "1.23.0" + "revision" : "9f7932f22ab6f64aafadc14491e694179b7d0f6f", + "version" : "4.14.3" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "a31236f24bfd2ea2f520a74575881f6731d7ae68", + "version" : "4.7.0" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "8c9a227476555c55837e569be71944e02a056b72", + "version" : "4.9.1" } }, { @@ -29,12 +65,12 @@ } }, { - "identity" : "swift-async-algorithms", + "identity" : "swift-algorithms", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", + "location" : "https://github.com/apple/swift-algorithms.git", "state" : { - "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", - "version" : "1.0.1" + "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", + "version" : "1.2.0" } }, { @@ -146,21 +182,12 @@ } }, { - "identity" : "swift-otel", - "kind" : "remoteSourceControl", - "location" : "https://github.com/slashmo/swift-otel.git", - "state" : { - "revision" : "8c271c7fed34a39f29c728598b3358fbdddf8ff4", - "version" : "0.9.0" - } - }, - { - "identity" : "swift-protobuf", + "identity" : "swift-numerics", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", + "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e17d61f26df0f0e06f58f6977ba05a097a720106", - "version" : "1.27.1" + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" } }, { @@ -172,15 +199,6 @@ "version" : "1.1.0" } }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", - "state" : { - "revision" : "24c800fb494fbee6e42bc156dc94232dc08971af", - "version" : "2.6.1" - } - }, { "identity" : "swift-system", "kind" : "remoteSourceControl", @@ -198,6 +216,24 @@ "revision" : "4d2cf7c64443cdf4df833d0bedd767bf9dbc49d9", "version" : "0.1.3" } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "a823735db57b46100b0c61cdfc5a08525b1e7cad", + "version" : "4.102.1" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "4232d34efa49f633ba61afde365d3896fc7f8740", + "version" : "2.15.0" + } } ], "version" : 3 diff --git a/Node/Package.swift b/Node/Package.swift index 99f96c09..3ec1ded5 100644 --- a/Node/Package.swift +++ b/Node/Package.swift @@ -19,6 +19,7 @@ let package = Package( .package(path: "../Utils"), .package(path: "../Blockchain"), .package(path: "../TracingUtils"), + .package(path: "../RPC"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -28,6 +29,7 @@ let package = Package( "Utils", "Blockchain", "TracingUtils", + "RPC", ] ), .testTarget( diff --git a/Node/Sources/Node/Node.swift b/Node/Sources/Node/Node.swift index 60872856..9dee611b 100644 --- a/Node/Sources/Node/Node.swift +++ b/Node/Sources/Node/Node.swift @@ -1,20 +1,40 @@ import Blockchain +import RPC import TracingUtils let logger = Logger(label: "node") +public typealias RPCConfig = Server.Config + public class Node { + public class Config { + public let rpc: Server.Config + public let protcol: ProtocolConfigRef + + public init(rpc: Server.Config, protocol: ProtocolConfigRef) { + self.rpc = rpc + protcol = `protocol` + } + } + public private(set) var blockchain: Blockchain + public private(set) var rpcServer: Server - public init(genesis: Genesis, config: ProtocolConfigRef) async throws { + public init(genesis: Genesis, config: Config) async throws { logger.debug("Initializing node") - let genesisState = try genesis.toState(config: config) + let genesisState = try genesis.toState(config: config.protcol) let dataProvider = await InMemoryDataProvider(genesis: genesisState) - blockchain = await Blockchain(config: config, dataProvider: dataProvider) + blockchain = await Blockchain(config: config.protcol, dataProvider: dataProvider) + + rpcServer = try Server(config: config.rpc, source: blockchain) } public func sayHello() { logger.info("Hello, World!") } + + public func wait() async throws { + try await rpcServer.wait() + } } diff --git a/RPC/Package.swift b/RPC/Package.swift index 24133223..83db47f3 100644 --- a/RPC/Package.swift +++ b/RPC/Package.swift @@ -17,6 +17,7 @@ let package = Package( ], dependencies: [ .package(path: "../Blockchain"), + .package(path: "../Utils"), .package(url: "https://github.com/vapor/vapor.git", from: "4.102.1"), .package(url: "https://github.com/vapor/async-kit.git", from: "1.19.1"), .package(url: "https://github.com/apple/swift-testing.git", branch: "0.10.0"), @@ -28,6 +29,7 @@ let package = Package( name: "RPC", dependencies: [ "Blockchain", + "Utils", .product(name: "Vapor", package: "vapor"), .product(name: "AsyncKit", package: "async-kit"), ] diff --git a/RPC/Sources/RPC/DataSource/Blockchain+DataSource.swift b/RPC/Sources/RPC/DataSource/Blockchain+DataSource.swift new file mode 100644 index 00000000..77df1cd1 --- /dev/null +++ b/RPC/Sources/RPC/DataSource/Blockchain+DataSource.swift @@ -0,0 +1,4 @@ +import Blockchain +import Utils + +extension Blockchain: DataSource {} diff --git a/RPC/Sources/RPC/DataSource/DataSource.swift b/RPC/Sources/RPC/DataSource/DataSource.swift new file mode 100644 index 00000000..00fffdce --- /dev/null +++ b/RPC/Sources/RPC/DataSource/DataSource.swift @@ -0,0 +1,9 @@ +import Blockchain +import Utils + +public protocol DataSource: Sendable { + func getBestBlock() async throws -> BlockRef + func getBlock(hash: Data32) async throws -> BlockRef? + + func importBlock(_: BlockRef) async throws +} diff --git a/RPC/Sources/RPC/Handlers/ChainHandler.swift b/RPC/Sources/RPC/Handlers/ChainHandler.swift new file mode 100644 index 00000000..08c7b4e9 --- /dev/null +++ b/RPC/Sources/RPC/Handlers/ChainHandler.swift @@ -0,0 +1,29 @@ +import Blockchain +import Foundation +import Utils + +struct ChainHandler { + let source: DataSource + + static func getHandlers(source: DataSource) -> [String: JSONRPCHandler] { + let handler = ChainHandler(source: source) + + return [ + "chain_getBlock": handler.getBlock, + ] + } + + func getBlock(request: JSONRequest) async throws -> any Encodable { + let hash = request.params?["hash"] as? String + if let hash { + guard let data = Data(fromHexString: hash), let data32 = Data32(data) else { + throw JSONError(code: -32602, message: "Invalid block hash") + } + let block = try await source.getBlock(hash: data32) + return block.map { ["hash": $0.hash.description, "parentHash": $0.header.parentHash.description] } + } else { + let block = try await source.getBestBlock() + return ["hash": block.hash.description, "parentHash": block.header.parentHash.description] + } + } +} diff --git a/RPC/Sources/RPC/Handlers/SystemHandler.swift b/RPC/Sources/RPC/Handlers/SystemHandler.swift new file mode 100644 index 00000000..6673c06e --- /dev/null +++ b/RPC/Sources/RPC/Handlers/SystemHandler.swift @@ -0,0 +1,13 @@ +struct SystemHandler { + static func getHandlers() -> [String: JSONRPCHandler] { + let handler = SystemHandler() + + return [ + "system_health": handler.health, + ] + } + + func health(request _: JSONRequest) async throws -> any Encodable { + true + } +} diff --git a/RPC/Sources/RPC/JSONRPC/AnyCodable.swift b/RPC/Sources/RPC/JSONRPC/AnyCodable.swift new file mode 100644 index 00000000..b181de5a --- /dev/null +++ b/RPC/Sources/RPC/JSONRPC/AnyCodable.swift @@ -0,0 +1,41 @@ +// swiftlint:disable all +// https://github.com/swiftlang/swift-docc/blob/afa67522d282c52ee7c647bf6c2463215c0d7891/Sources/SwiftDocC/Infrastructure/Communication/Foundation/AnyCodable.swift +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +/// A type-erased codable value. +/// +/// An `AnyCodable` value forwards encoding and decoding operations to the underlying base. +public struct AnyCodable: Codable, CustomDebugStringConvertible { + /// The base encodable value. + public var value: Encodable + + /// Creates a codable value that wraps the given base. + public init(_ encodable: Encodable) { + value = encodable + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = JSON.null + } else { + value = try container.decode(JSON.self) + } + } + + public func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } + + public var debugDescription: String { + String(describing: value) + } +} diff --git a/RPC/Sources/RPC/JSONRPC/JSON.swift b/RPC/Sources/RPC/JSONRPC/JSON.swift new file mode 100644 index 00000000..87a96b87 --- /dev/null +++ b/RPC/Sources/RPC/JSONRPC/JSON.swift @@ -0,0 +1,168 @@ +// swiftlint:disable all +// https://github.com/swiftlang/swift-docc/blob/afa67522d282c52ee7c647bf6c2463215c0d7891/Sources/SwiftDocC/Infrastructure/Communication/Foundation/JSON.swift +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import Foundation + +indirect enum JSON: Codable { + case dictionary([String: JSON]) + case array([JSON]) + case string(String) + case number(Double) + case boolean(Bool) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let boolValue = try? container.decode(Bool.self) { + self = .boolean(boolValue) + } else if let numericValue = try? container.decode(Double.self) { + self = .number(numericValue) + } else if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let arrayValue = try? container.decode([JSON].self) { + self = .array(arrayValue) + } else { + self = try .dictionary(container.decode([String: JSON].self)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .dictionary(dictionary): + try container.encode(dictionary) + case let .array(array): + try container.encode(array) + case let .string(string): + try container.encode(string) + case let .number(number): + try container.encode(number) + case let .boolean(boolean): + try container.encode(boolean) + case .null: + try container.encodeNil() + } + } +} + +extension JSON: CustomDebugStringConvertible { + var debugDescription: String { + let encoder = JSONEncoder() + if #available(macOS 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } else { + encoder.outputFormatting = [.prettyPrinted] + } + + do { + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "JSON(error decoding UTF8 string)" + } catch { + return "JSON(error encoding description: '\(error.localizedDescription)')" + } + } +} + +extension JSON { + subscript(key: Any) -> JSON? { + if let array, let index = key as? Int, index < array.count { + array[index] + } else if let dic = dictionary, let key = key as? String, let obj = dic[key] { + obj + } else { + nil + } + } + + /// Returns a `JSON` dictionary, if possible. + var dictionary: [String: JSON]? { + switch self { + case let .dictionary(dict): + dict + default: + nil + } + } + + /// Returns a `JSON` array, if possible. + var array: [JSON]? { + switch self { + case let .array(array): + array + default: + nil + } + } + + /// Returns a `String` value, if possible. + var string: String? { + switch self { + case let .string(value): + value + default: + nil + } + } + + /// Returns a `Double` value, if possible. + var number: Double? { + switch self { + case let .number(number): + number + default: + nil + } + } + + /// Returns a `Bool` value, if possible. + var bool: Bool? { + switch self { + case let .boolean(value): + value + default: + nil + } + } +} + +extension JSON { + /// An integer coding key. + struct IntegerKey: CodingKey { + var intValue: Int? + var stringValue: String + + init(_ value: Int) { + intValue = value + stringValue = value.description + } + + init(_ value: String) { + intValue = nil + stringValue = value + } + + init?(intValue: Int) { + self.init(intValue) + } + + init?(stringValue: String) { + guard let intValue = Int(stringValue) else { + return nil + } + + self.intValue = intValue + self.stringValue = stringValue + } + } +} diff --git a/RPC/Sources/RPC/JSONRPC/JSONRPC.swift b/RPC/Sources/RPC/JSONRPC/JSONRPC.swift new file mode 100644 index 00000000..73d12502 --- /dev/null +++ b/RPC/Sources/RPC/JSONRPC/JSONRPC.swift @@ -0,0 +1,26 @@ +import Vapor + +struct JSONRequest: Content { + let jsonrpc: String + let method: String + let params: JSON? + let id: Int +} + +struct JSONResponse: Content { + let jsonrpc: String + let result: AnyCodable? + let error: JSONError? + let id: Int? +} + +struct JSONError: Content, Error { + let code: Int + let message: String +} + +extension JSONError { + static func methodNotFound(_ method: String) -> JSONError { + JSONError(code: -32601, message: "Method not found: \(method)") + } +} diff --git a/RPC/Sources/RPC/JSONRPC/JSONRPCController.swift b/RPC/Sources/RPC/JSONRPC/JSONRPCController.swift new file mode 100644 index 00000000..ef26a6c6 --- /dev/null +++ b/RPC/Sources/RPC/JSONRPC/JSONRPCController.swift @@ -0,0 +1,84 @@ +import Blockchain +import TracingUtils +import Utils +import Vapor + +let logger = Logger(label: "RPC.RPCController") + +typealias JSONRPCHandler = @Sendable (JSONRequest) async throws -> any Encodable + +final class JSONRPCController: RouteCollection, Sendable { + let handlers: [String: JSONRPCHandler] + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + init(handlers: [String: JSONRPCHandler]) { + self.handlers = handlers + } + + func boot(routes: RoutesBuilder) throws { + // HTTP JSON-RPC route + routes.post("", use: handleRPCRequest) + + // WebSocket JSON-RPC route + routes.webSocket("", onUpgrade: handleWebSocket) + } + + func handleRPCRequest(_ req: Request) async throws -> Response { + let jsonRequest = try req.content.decode(JSONRequest.self) + let jsonResponse = await handleRequest(jsonRequest) + return try Response(status: .ok, body: .init(data: encoder.encode(jsonResponse))) + } + + func handleWebSocket(req _: Request, ws: WebSocket) { + ws.onText { ws, text in + Task { + await self.processWebSocketRequest(ws, text: text) + } + } + + ws.onBinary { ws, _ in + logger.debug("Received binary data on WebSocket. Closing connection.") + try? await ws.close() + } + } + + private func processWebSocketRequest(_ ws: WebSocket, text: String) async { + do { + let jsonRequest = try decoder.decode(JSONRequest.self, from: Data(text.utf8)) + let jsonResponse = await handleRequest(jsonRequest) + let responseData = try encoder.encode(jsonResponse) + try await ws.send(raw: responseData, opcode: .text) + } catch { + logger.debug("Failed to decode JSON request: \(error)") + + let rpcError = JSONError(code: -32600, message: "Invalid Request") + let rpcResponse = JSONResponse(jsonrpc: "2.0", result: nil, error: rpcError, id: nil) + + do { + let responseData = try encoder.encode(rpcResponse) + try await ws.send(raw: responseData, opcode: .text) + } catch { + logger.error("Failed to send WebSocket error response: \(error)") + try? await ws.close() + } + } + } + + func handleRequest(_ request: JSONRequest) async -> JSONResponse { + do { + let method = request.method + guard let handler = handlers[method] else { + return JSONResponse(jsonrpc: "2.0", result: nil, error: JSONError.methodNotFound(method), id: request.id) + } + + let res = try await handler(request) + return JSONResponse(jsonrpc: "2.0", result: AnyCodable(res), error: nil, id: request.id) + } catch { + logger.error("Failed to handle JSON request: \(error)") + + let rpcError = JSONError(code: -32600, message: "Invalid Request") + return JSONResponse(jsonrpc: "2.0", result: nil, error: rpcError, id: request.id) + } + } +} diff --git a/RPC/Sources/RPC/RPC.swift b/RPC/Sources/RPC/RPC.swift deleted file mode 100644 index 7b45c1e6..00000000 --- a/RPC/Sources/RPC/RPC.swift +++ /dev/null @@ -1,17 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book -import Vapor - -public func startServer() throws { - let env = try Environment.detect() - let app = Application(env) - defer { app.shutdown() } - try configure(app) - try app.run() -} - -public func configure(_ app: Application) throws { - // Register routes - let rpcController = RPCController() - try app.register(collection: rpcController) -} diff --git a/RPC/Sources/RPC/RPCController.swift b/RPC/Sources/RPC/RPCController.swift deleted file mode 100644 index bf330fd9..00000000 --- a/RPC/Sources/RPC/RPCController.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Blockchain -import Vapor - -final class RPCController: RouteCollection { - func boot(routes: RoutesBuilder) throws { - // HTTP JSON-RPC route - routes.post("rpc", use: handleRPCRequest) - - // WebSocket JSON-RPC route - routes.webSocket("ws", onUpgrade: handleWebSocket) - } - - func handleRPCRequest(_ req: Request) -> EventLoopFuture { - do { - let rpcRequest = try req.content.decode(RPCRequest.self) - // Handle the JSON-RPC request - let result = try RPCController.handleMethod(rpcRequest.method, params: rpcRequest.params) - let rpcResponse = RPCResponse(jsonrpc: "2.0", result: AnyContent(result ?? ""), error: nil, id: rpcRequest.id) - return try req.eventLoop.makeSucceededFuture(Response(status: .ok, body: .init(data: JSONEncoder().encode(rpcResponse)))) - } catch { - let rpcError = RPCError(code: -32600, message: "Invalid Request") - let rpcResponse = RPCResponse(jsonrpc: "2.0", result: nil, error: rpcError, id: nil) - - do { - let responseData = try JSONEncoder().encode(rpcResponse) - return req.eventLoop.makeSucceededFuture(Response(status: .badRequest, body: .init(data: responseData))) - } catch { - print("Failed to encode error response: \(error)") - return req.eventLoop.makeSucceededFuture(Response(status: .badRequest, body: .init(data: Data()))) - } - } - } - - func handleWebSocket(req _: Request, ws: WebSocket) { - ws.onText { ws, text in - Task { - await RPCController.processWebSocketRequest(ws, text: text) - } - } - } - - private static func processWebSocketRequest(_ ws: WebSocket, text: String) async { - do { - let rpcRequest = try JSONDecoder().decode(RPCRequest.self, from: Data(text.utf8)) - let result = try handleMethod(rpcRequest.method, params: rpcRequest.params?.value) - let rpcResponse = RPCResponse(jsonrpc: "2.0", result: AnyContent(result ?? ""), error: nil, id: rpcRequest.id) - let responseData = try JSONEncoder().encode(rpcResponse) - try await ws.send(String(decoding: responseData, as: UTF8.self)) - } catch { - let rpcError = RPCError(code: -32600, message: "Invalid Request") - let rpcResponse = RPCResponse(jsonrpc: "2.0", result: nil, error: rpcError, id: nil) - - do { - let responseData = try JSONEncoder().encode(rpcResponse) - try await ws.send(String(decoding: responseData, as: UTF8.self)) - } catch { - print("Failed to send WebSocket error response: \(error)") - } - } - } - - static func handleChainGetBlock(params _: BlockParams?) -> CodableBlock? { - // Fetch the block by hash or number - nil - } - - static func handleChainGetHeader(params _: HeaderParams?) -> CodableHeader? { - // Fetch the header by hash or number - nil - } - - static func handleMethod(_ method: String, params: Any?) throws -> Any? { - switch method { - case "health": - return true - case "chain_getBlock": - return handleChainGetBlock(params: params as? BlockParams) - case "chain_getHeader": - return handleChainGetHeader(params: params as? HeaderParams) - default: - throw RPCError(code: -32601, message: "Method not found") - } - } -} - -extension RPCController: @unchecked Sendable {} diff --git a/RPC/Sources/RPC/RPCModels.swift b/RPC/Sources/RPC/RPCModels.swift deleted file mode 100644 index 20c4edf4..00000000 --- a/RPC/Sources/RPC/RPCModels.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Blockchain -import Utils -import Vapor - -public struct RPCRequest: Content { - public let jsonrpc: String - public let method: String - public let params: T? - public let id: Int? -} - -public struct RPCResponse: Content { - public let jsonrpc: String - public let result: T? - public let error: RPCError? - public let id: Int? -} - -public struct RPCError: Content, Error { - public let code: Int - public let message: String -} - -public struct RPCParams: Content { - // Generic params structure if needed -} - -public struct RPCResult: Content { - // Generic result structure if needed -} - -public struct BlockParams: Content { - let blockHash: String? -} - -public struct HeaderParams: Content { - let blockHash: String? -} - -public struct CodableBlock: Codable { - let property1: String - let property2: String - // Add all properties from the original Block type - - init(from block: Block) { - property1 = block.header.parentHash.description - property2 = block.header.extrinsicsRoot.description - // Initialize all properties from the original Block type - } -} - -public struct CodableHeader: Codable { - let property1: String - let property2: String - - init(from header: Header) { - property1 = header.parentHash.description - property2 = header.extrinsicsRoot.description - } -} - -public struct AnyContent: Content { - public let value: Any - - public init(_ value: Any) { - self.value = value - } - - public func encode(to encoder: Encoder) throws { - if let value = value as? CodableBlock { - try value.encode(to: encoder) - } else if let value = value as? CodableHeader { - try value.encode(to: encoder) - } else { - var container = encoder.singleValueContainer() - try container.encodeNil() - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - // Assuming a common set of types we expect - if let value = try? container.decode(CodableBlock.self) { - self.value = value - } else if let value = try? container.decode(CodableHeader.self) { - self.value = value - } else { - value = "" - } - } -} diff --git a/RPC/Sources/RPC/Server.swift b/RPC/Sources/RPC/Server.swift new file mode 100644 index 00000000..bcf29b64 --- /dev/null +++ b/RPC/Sources/RPC/Server.swift @@ -0,0 +1,49 @@ +import Foundation +import Vapor + +public class Server { + public enum Error: Swift.Error { + case invalidListenAddress(address: String) + } + + public class Config { + public let listenAddress: String + public let port: Int + + public init(listenAddress: String, port: Int) { + self.listenAddress = listenAddress + self.port = port + } + } + + private let config: Config + private let source: DataSource + private let app: Application + + public init(config: Config, source: DataSource) throws { + self.config = config + self.source = source + + let env = try Environment.detect() + app = Application(env) + + var handlers: [String: JSONRPCHandler] = SystemHandler.getHandlers() + handlers.merge(ChainHandler.getHandlers(source: source)) { _, new in new } + + // Register routes + let rpcController = JSONRPCController(handlers: handlers) + try app.register(collection: rpcController) + + app.http.server.configuration.address = .hostname(config.listenAddress, port: config.port) + + try app.start() + } + + deinit { + app.shutdown() + } + + public func wait() async throws { + try await app.running?.onStop.get() + } +} diff --git a/RPC/Tests/RPCTests/JSONRPCControllerTests.swift b/RPC/Tests/RPCTests/JSONRPCControllerTests.swift new file mode 100644 index 00000000..4d836f2a --- /dev/null +++ b/RPC/Tests/RPCTests/JSONRPCControllerTests.swift @@ -0,0 +1,32 @@ +import Blockchain +@testable import RPC +import Testing +import TracingUtils +import Vapor +import XCTVapor + +final class JSONRPCControllerTests { + var app: Application + + init() throws { + app = Application(.testing) + + let rpcController = JSONRPCController(handlers: SystemHandler.getHandlers()) + try app.register(collection: rpcController) + } + + deinit { + app.shutdown() + } + + @Test func health() throws { + let req = JSONRequest(jsonrpc: "2.0", method: "system_health", params: nil, id: 1) + var buffer = ByteBuffer() + try buffer.writeJSONEncodable(req) + try app.test(.POST, "/", headers: ["Content-Type": "application/json"], body: buffer) { res in + #expect(res.status == .ok) + let resp = try res.content.decode(JSONResponse.self, using: JSONDecoder()) + #expect(resp.result?.value != nil) + } + } +} diff --git a/RPC/Tests/RPCTests/MockDataSource.swift b/RPC/Tests/RPCTests/MockDataSource.swift new file mode 100644 index 00000000..b7e21356 --- /dev/null +++ b/RPC/Tests/RPCTests/MockDataSource.swift @@ -0,0 +1,25 @@ +import Blockchain +import RPC +import Utils + +actor MockDataSource: DataSource, @unchecked Sendable { + var bestBlock: BlockRef + var blocks: [Data32: BlockRef] = [:] + var importedBlocks: [BlockRef] = [] + + init(bestBlock: BlockRef) { + self.bestBlock = bestBlock + } + + func getBestBlock() async throws -> BlockRef { + bestBlock + } + + func getBlock(hash: Data32) async throws -> BlockRef? { + blocks[hash] + } + + func importBlock(_: BlockRef) async throws { + importedBlocks.append(bestBlock) + } +} diff --git a/RPC/Tests/RPCTests/RPCTests.swift b/RPC/Tests/RPCTests/RPCTests.swift deleted file mode 100644 index d4c30680..00000000 --- a/RPC/Tests/RPCTests/RPCTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -@testable import RPC -import Testing -import Vapor -import XCTVapor - -final class RPCControllerTests: @unchecked Sendable { - var app: Application - - init() throws { - app = Application(.testing) - - try configure(app) - } - - deinit { - app.shutdown() - } - - @Test func serviceInited() throws { - try app.test(.GET, "health") { res in - XCTAssertEqual(res.status, .ok) - XCTAssertEqual(res.body.string, "true") - } - } -} diff --git a/TracingUtils/Sources/TracingUtils/Tracing.swift b/TracingUtils/Sources/TracingUtils/export.swift similarity index 100% rename from TracingUtils/Sources/TracingUtils/Tracing.swift rename to TracingUtils/Sources/TracingUtils/export.swift diff --git a/TracingUtils/Sources/TracingUtils/unreachable.swift b/TracingUtils/Sources/TracingUtils/unreachable.swift new file mode 100644 index 00000000..fb382c29 --- /dev/null +++ b/TracingUtils/Sources/TracingUtils/unreachable.swift @@ -0,0 +1,17 @@ +import Logging + +let logger = Logger(label: "tracing-utils.assertions") + +enum AssertionError: Error { + case unreachable(String) +} + +public func throwUnreachable(_ msg: String, file: String = #fileID, function: String = #function, line: UInt = #line) throws -> Never { + unreachable(msg, file: file, function: function, line: line) + throw AssertionError.unreachable(msg) +} + +public func unreachable(_ msg: String, file: String = #fileID, function: String = #function, line: UInt = #line) { + logger.error("unreachable: \(msg)", metadata: nil, source: nil, file: file, function: function, line: line) + assertionFailure(msg) +} diff --git a/Utils/Sources/Utils/Data+Utils.swift b/Utils/Sources/Utils/Data+Utils.swift index a8340d33..4508b6a6 100644 --- a/Utils/Sources/Utils/Data+Utils.swift +++ b/Utils/Sources/Utils/Data+Utils.swift @@ -1,7 +1,7 @@ import Foundation extension Data { - init?(fromHexString hexString: String) { + public init?(fromHexString hexString: String) { guard !hexString.isEmpty else { return nil }