From 33a302fc517823d8fbe74c65a45059f5c4143fc2 Mon Sep 17 00:00:00 2001 From: anquii <50277099+anquii@users.noreply.github.com> Date: Sat, 5 Aug 2023 11:31:50 +0100 Subject: [PATCH] Added sources and tests --- Package.resolved | 14 ++ Package.swift | 21 ++ README.md | 16 +- Sources/ElectrumX/ElectrumX.swift | 21 ++ Sources/ElectrumX/ElectrumXError.swift | 4 + Sources/ElectrumX/ElectrumXService.swift | 209 ++++++++++++++++++ Sources/ElectrumX/Entities/Balance.swift | 4 + .../Entities/ConfirmedTransaction.swift | 15 ++ Sources/ElectrumX/Entities/History.swift | 18 ++ .../ElectrumX/Entities/HistoryParams.swift | 16 ++ .../Entities/ScriptHashNotification.swift | 10 + .../Entities/UnconfirmedTransaction.swift | 18 ++ .../Entities/UnspentTransactionOutput.swift | 22 ++ Sources/ElectrumX/Entities/Version.swift | 10 + .../ElectrumX/Entities/VersionParams.swift | 15 ++ Sources/ElectrumX/NWCommunicating.swift | 7 + Sources/ElectrumX/NWConnecting.swift | 10 + Sources/ElectrumX/RequestID.swift | 8 + .../ElectrumXServiceTests.swift | 112 ++++++++++ 19 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/ElectrumX/ElectrumX.swift create mode 100644 Sources/ElectrumX/ElectrumXError.swift create mode 100644 Sources/ElectrumX/ElectrumXService.swift create mode 100644 Sources/ElectrumX/Entities/Balance.swift create mode 100644 Sources/ElectrumX/Entities/ConfirmedTransaction.swift create mode 100644 Sources/ElectrumX/Entities/History.swift create mode 100644 Sources/ElectrumX/Entities/HistoryParams.swift create mode 100644 Sources/ElectrumX/Entities/ScriptHashNotification.swift create mode 100644 Sources/ElectrumX/Entities/UnconfirmedTransaction.swift create mode 100644 Sources/ElectrumX/Entities/UnspentTransactionOutput.swift create mode 100644 Sources/ElectrumX/Entities/Version.swift create mode 100644 Sources/ElectrumX/Entities/VersionParams.swift create mode 100644 Sources/ElectrumX/NWCommunicating.swift create mode 100644 Sources/ElectrumX/NWConnecting.swift create mode 100644 Sources/ElectrumX/RequestID.swift create mode 100644 Tests/ElectrumXTests/ElectrumXServiceTests.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..b68d31d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "jsonrpc2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/anquii/JSONRPC2.git", + "state" : { + "revision" : "e0c591b3f9aa93d3236855034289bf061cf644de", + "version" : "2.0.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..d4c60a8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:5.7 + +import PackageDescription + +let package = Package( + name: "ElectrumX", + platforms: [ + .macOS(.v10_15), + .iOS(.v13) + ], + products: [ + .library(name: "ElectrumX", targets: ["ElectrumX"]) + ], + dependencies: [ + .package(url: "https://github.com/anquii/JSONRPC2.git", exact: "2.0.0") + ], + targets: [ + .target(name: "ElectrumX", dependencies: ["JSONRPC2"]), + .testTarget(name: "ElectrumXTests", dependencies: ["ElectrumX"]) + ] +) diff --git a/README.md b/README.md index 8be21c2..68d3445 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Electrum +# ElectrumX [![Platform](https://img.shields.io/badge/Platforms-macOS%20%7C%20iOS-blue)](#platforms) [![Swift Package Manager compatible](https://img.shields.io/badge/SPM-compatible-orange)](#swift-package-manager) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/anquii/Electrum/blob/main/LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/anquii/ElectrumX/blob/main/LICENSE) A [JSON-RPC 2.0](https://www.jsonrpc.org/specification) client implementation of [ElectrumX](https://github.com/spesmilo/electrumx/pull/90) in Swift. @@ -16,19 +16,25 @@ A [JSON-RPC 2.0](https://www.jsonrpc.org/specification) client implementation of Add the following line to your `Package.swift` file: ```swift -.package(url: "https://github.com/anquii/Electrum.git", from: "0.1.0") +.package(url: "https://github.com/anquii/ElectrumX.git", from: "0.1.0") ``` ...or integrate with Xcode via `File -> Swift Packages -> Add Package Dependency...` using the URL of the repository. ## Usage ```swift -import Electrum +import ElectrumX + +let service = ElectrumXService(endpoint: endpoint, parameters: parameters) +try await service.startConnection() +try await service.version(params: versionParams) +let response = try await service.balance(scriptHash: scriptHash) +try await service.cancelConnection() ``` ## License -`Electrum` is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for more information. +`ElectrumX` is licensed under the terms of the MIT license. See the [LICENSE](LICENSE) file for more information. ## Donations diff --git a/Sources/ElectrumX/ElectrumX.swift b/Sources/ElectrumX/ElectrumX.swift new file mode 100644 index 0000000..5113851 --- /dev/null +++ b/Sources/ElectrumX/ElectrumX.swift @@ -0,0 +1,21 @@ +import Combine +import JSONRPC2 + +public protocol ElectrumX { + var scriptHashNotificationPublisher: any Publisher { get } + + func ping() async throws + func version(params: VersionParams) async throws -> JSONRPC2Response + func history(params: HistoryParams) async throws -> JSONRPC2Response + func balance(scriptHash: String) async throws -> JSONRPC2Response + func unconfirmedTransactions(scriptHash: String) async throws -> JSONRPC2Response<[UnconfirmedTransaction], JSONRPC2Error> + func unspentTransactionOutputs(scriptHash: String) async throws -> JSONRPC2Response<[UnspentTransactionOutput], JSONRPC2Error> + /// Returns the raw transaction as hex. + func rawTransaction(hash: String) async throws -> JSONRPC2Response + /// Returns the hash of the broadcasted transaction. + func broadcast(rawTransaction: String) async throws -> JSONRPC2Response + /// Returns the status of the script hash. + func subscribe(scriptHash: String) async throws -> JSONRPC2Response + /// Returns a bool to specify if the script hash was subscribed to. + func unsubscribe(scriptHash: String) async throws -> JSONRPC2Response +} diff --git a/Sources/ElectrumX/ElectrumXError.swift b/Sources/ElectrumX/ElectrumXError.swift new file mode 100644 index 0000000..2bfe500 --- /dev/null +++ b/Sources/ElectrumX/ElectrumXError.swift @@ -0,0 +1,4 @@ +public enum ElectrumXError: Error { + case connectionTimeout + case responseTimeout +} diff --git a/Sources/ElectrumX/ElectrumXService.swift b/Sources/ElectrumX/ElectrumXService.swift new file mode 100644 index 0000000..e298734 --- /dev/null +++ b/Sources/ElectrumX/ElectrumXService.swift @@ -0,0 +1,209 @@ +import Foundation +import Combine +import Network +import JSONRPC2 + +/// Supports TCP and TLS over TCP. +public final class ElectrumXService { + private static let newlineCharacter = UInt8(10) + private static let newlineCharacterData = Data([newlineCharacter]) + + private let queue = DispatchQueue(label: "\(#file).\(UUID().uuidString)", attributes: .concurrent) + private let requestID = RequestID() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let connection: NWConnection + private let connectionTimeout: DispatchQueue.SchedulerTimeType.Stride + private let responseTimeout: DispatchQueue.SchedulerTimeType.Stride + private let dataPublisher = PassthroughSubject() + private let connectionStateWithErrorPublisher = PassthroughSubject() + private let _connectionStatePublisher = PassthroughSubject() + private let _scriptHashNotificationPublisher = PassthroughSubject() + private var cancellables = Set() + + public init( + endpoint: NWEndpoint, + parameters: NWParameters, + connectionTimeout: DispatchQueue.SchedulerTimeType.Stride = .seconds(30), + responseTimeout: DispatchQueue.SchedulerTimeType.Stride = .seconds(60) + ) { + connection = NWConnection(to: endpoint, using: parameters) + self.connectionTimeout = connectionTimeout + self.responseTimeout = responseTimeout + observeConnectionState() + observeScriptHashNotifications() + } +} + +// MARK: - NWConnecting +extension ElectrumXService: NWConnecting { + public var connectionState: NWConnection.State { + connection.state + } + + public var connectionStatePublisher: any Publisher { + _connectionStatePublisher.eraseToAnyPublisher() + } + + public func startConnection() async throws { + guard connectionState != .ready else { + return + } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let receiveCompletion = { (completion: Subscribers.Completion) in + if case .failure(let error) = completion { + continuation.resume(throwing: error) + } + } + let receiveValue = { [weak self] (value: NWConnection.State) in + self?.receiveNextData() + continuation.resume() + } + connectionStateWithErrorPublisher + .timeout(connectionTimeout, scheduler: queue, customError: { .connectionTimeout }) + .first { $0 == .ready } + .sink(receiveCompletion: receiveCompletion, receiveValue: receiveValue) + .store(in: &cancellables) + connection.start(queue: queue) + } + } + + public func cancelConnection() async throws { + try await sendFinalMessage() + connection.cancel() + } +} + +// MARK: - NWCommunicating +extension ElectrumXService: NWCommunicating { + func sendMessage(data: Data) async throws { + try await sendMessage(data: data + Self.newlineCharacterData, contentContext: .defaultMessage) + } + + func sendFinalMessage() async throws { + try await sendMessage(contentContext: .finalMessage) + } + + func receiveNextData() { + connection.receive(minimumIncompleteLength: 1, maximumLength: Int(UInt16.max)) { [weak self] data, _, isComplete, error in + guard let data, error == nil else { + return + } + let separatedDatas = data.dropLast(1).split(separator: Self.newlineCharacter) + for separatedData in separatedDatas { + self?.dataPublisher.send(separatedData) + } + if !isComplete, self?.connectionState == .ready { + self?.receiveNextData() + } + } + } +} + +// MARK: - ElectrumX +extension ElectrumXService: ElectrumX { + public var scriptHashNotificationPublisher: any Publisher { + _scriptHashNotificationPublisher.eraseToAnyPublisher() + } + + public func ping() async throws { + let notification = JSONRPC2Request(method: "server.ping") + let encodedNotification = try encoder.encode(notification) + try await sendMessage(data: encodedNotification) + } + + @discardableResult + public func version(params: VersionParams) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "server.version", params: params) + } + + public func history(params: HistoryParams) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.get_history", params: params) + } + + public func balance(scriptHash: String) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.get_balance", params: [scriptHash]) + } + + public func unconfirmedTransactions(scriptHash: String) async throws -> JSONRPC2Response<[UnconfirmedTransaction], JSONRPC2Error> { + try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.get_mempool", params: [scriptHash]) + } + + public func unspentTransactionOutputs(scriptHash: String) async throws -> JSONRPC2Response<[UnspentTransactionOutput], JSONRPC2Error> { + try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.listunspent", params: [scriptHash]) + } + + public func rawTransaction(hash: String) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "blockchain.transaction.get", params: [hash]) + } + + public func broadcast(rawTransaction: String) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "blockchain.transaction.broadcast", params: [rawTransaction]) + } + + public func subscribe(scriptHash: String) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.subscribe", params: [scriptHash]) + } + + public func unsubscribe(scriptHash: String) async throws -> JSONRPC2Response { + try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.unsubscribe", params: [scriptHash]) + } +} + +// MARK: - Helpers +fileprivate extension ElectrumXService { + func observeConnectionState() { + connection.stateUpdateHandler = { [weak self] state in + self?.connectionStateWithErrorPublisher.send(state) + self?._connectionStatePublisher.send(state) + } + } + + func observeScriptHashNotifications() { + dataPublisher + .decode(type: JSONRPC2ServerNotificationWithParams.self, decoder: decoder) + .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] in + self?._scriptHashNotificationPublisher.send($0.params) + }) + .store(in: &cancellables) + } + + func sendMessage(data: Data? = nil, contentContext: NWConnection.ContentContext) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, contentContext: contentContext, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + func receiveResponse(requestID: String) async throws -> JSONRPC2Response { + try await withCheckedThrowingContinuation { continuation in + let receiveCompletion = { (completion: Subscribers.Completion) in + if case .failure(let error) = completion { + continuation.resume(throwing: error) + } + } + let receiveValue = { (value: JSONRPC2Response) in + continuation.resume(returning: value) + } + dataPublisher + .timeout(responseTimeout, scheduler: queue, customError: { .responseTimeout }) + .decode(type: JSONRPC2Response.self, decoder: decoder) + .first { $0.id == requestID && $0.jsonrpc == "2.0" } + .sink(receiveCompletion: receiveCompletion, receiveValue: receiveValue) + .store(in: &cancellables) + } + } + + func sendMessageAndReceiveResponse(method: String, params: P) async throws -> JSONRPC2Response { + let requestID = await requestID.next() + let request = JSONRPC2Request(method: method, params: params, id: requestID) + let encodedRequest = try encoder.encode(request) + try await sendMessage(data: encodedRequest) + return try await receiveResponse(requestID: requestID) + } +} diff --git a/Sources/ElectrumX/Entities/Balance.swift b/Sources/ElectrumX/Entities/Balance.swift new file mode 100644 index 0000000..0c51769 --- /dev/null +++ b/Sources/ElectrumX/Entities/Balance.swift @@ -0,0 +1,4 @@ +public struct Balance: Decodable { + public let confirmed: UInt64 + public let unconfirmed: UInt64 +} diff --git a/Sources/ElectrumX/Entities/ConfirmedTransaction.swift b/Sources/ElectrumX/Entities/ConfirmedTransaction.swift new file mode 100644 index 0000000..1cdf38d --- /dev/null +++ b/Sources/ElectrumX/Entities/ConfirmedTransaction.swift @@ -0,0 +1,15 @@ +public struct ConfirmedTransaction: Decodable { + public let height: UInt64 + public let hash: String + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + height = try container.decode(UInt64.self, forKey: .height) + hash = try container.decode(String.self, forKey: .hash) + } + + private enum CodingKeys: String, CodingKey { + case height + case hash = "tx_hash" + } +} diff --git a/Sources/ElectrumX/Entities/History.swift b/Sources/ElectrumX/Entities/History.swift new file mode 100644 index 0000000..bc54e3c --- /dev/null +++ b/Sources/ElectrumX/Entities/History.swift @@ -0,0 +1,18 @@ +public struct History: Decodable { + public let heightRange: ClosedRange + public let confirmedTransactions: [ConfirmedTransaction] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let fromHeight = try container.decode(UInt64.self, forKey: .fromHeight) + let toHeight = try container.decode(UInt64.self, forKey: .toHeight) + heightRange = fromHeight...toHeight + confirmedTransactions = try container.decode([ConfirmedTransaction].self, forKey: .confirmedTransactions) + } + + private enum CodingKeys: String, CodingKey { + case fromHeight = "from_height" + case toHeight = "to_height" + case confirmedTransactions = "history" + } +} diff --git a/Sources/ElectrumX/Entities/HistoryParams.swift b/Sources/ElectrumX/Entities/HistoryParams.swift new file mode 100644 index 0000000..32ba944 --- /dev/null +++ b/Sources/ElectrumX/Entities/HistoryParams.swift @@ -0,0 +1,16 @@ +public struct HistoryParams: Encodable { + public let scriptHash: String + public let heightRange: ClosedRange + + public init(scriptHash: String, heightRange: ClosedRange) { + self.scriptHash = scriptHash + self.heightRange = heightRange + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(scriptHash) + try container.encode(heightRange.lowerBound) + try container.encode(heightRange.upperBound) + } +} diff --git a/Sources/ElectrumX/Entities/ScriptHashNotification.swift b/Sources/ElectrumX/Entities/ScriptHashNotification.swift new file mode 100644 index 0000000..fa88d97 --- /dev/null +++ b/Sources/ElectrumX/Entities/ScriptHashNotification.swift @@ -0,0 +1,10 @@ +public struct ScriptHashNotification: Decodable { + public let scriptHash: String + public let scriptHashStatus: String + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + scriptHash = try container.decode(String.self) + scriptHashStatus = try container.decode(String.self) + } +} diff --git a/Sources/ElectrumX/Entities/UnconfirmedTransaction.swift b/Sources/ElectrumX/Entities/UnconfirmedTransaction.swift new file mode 100644 index 0000000..973f5ef --- /dev/null +++ b/Sources/ElectrumX/Entities/UnconfirmedTransaction.swift @@ -0,0 +1,18 @@ +public struct UnconfirmedTransaction: Decodable { + public let height: UInt64 + public let hash: String + public let fee: UInt64 + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + height = try container.decode(UInt64.self, forKey: .height) + hash = try container.decode(String.self, forKey: .hash) + fee = try container.decode(UInt64.self, forKey: .fee) + } + + private enum CodingKeys: String, CodingKey { + case height + case hash = "tx_hash" + case fee + } +} diff --git a/Sources/ElectrumX/Entities/UnspentTransactionOutput.swift b/Sources/ElectrumX/Entities/UnspentTransactionOutput.swift new file mode 100644 index 0000000..83470d6 --- /dev/null +++ b/Sources/ElectrumX/Entities/UnspentTransactionOutput.swift @@ -0,0 +1,22 @@ +public struct UnspentTransactionOutput: Decodable { + public let height: UInt64 + public let position: Int + public let hash: String + public let value: UInt64 + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + height = try container.decode(UInt64.self, forKey: .height) + position = try container.decode(Int.self, forKey: .position) + hash = try container.decode(String.self, forKey: .hash) + value = try container.decode(UInt64.self, forKey: .value) + } + + private enum CodingKeys: String, CodingKey { + case height + case position = "tx_pos" + case hash = "tx_hash" + case value + } + +} diff --git a/Sources/ElectrumX/Entities/Version.swift b/Sources/ElectrumX/Entities/Version.swift new file mode 100644 index 0000000..f4cfe42 --- /dev/null +++ b/Sources/ElectrumX/Entities/Version.swift @@ -0,0 +1,10 @@ +public struct Version: Decodable { + public let softwareVersion: String + public let protocolVersion: String + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + softwareVersion = try container.decode(String.self) + protocolVersion = try container.decode(String.self) + } +} diff --git a/Sources/ElectrumX/Entities/VersionParams.swift b/Sources/ElectrumX/Entities/VersionParams.swift new file mode 100644 index 0000000..b251e7c --- /dev/null +++ b/Sources/ElectrumX/Entities/VersionParams.swift @@ -0,0 +1,15 @@ +public struct VersionParams: Encodable { + public let clientName: String + public let protocolVersion: String + + public init(clientName: String = "", protocolVersion: String) { + self.clientName = clientName + self.protocolVersion = protocolVersion + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(clientName) + try container.encode(protocolVersion) + } +} diff --git a/Sources/ElectrumX/NWCommunicating.swift b/Sources/ElectrumX/NWCommunicating.swift new file mode 100644 index 0000000..4561717 --- /dev/null +++ b/Sources/ElectrumX/NWCommunicating.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol NWCommunicating { + func sendMessage(data: Data) async throws + func sendFinalMessage() async throws + func receiveNextData() +} diff --git a/Sources/ElectrumX/NWConnecting.swift b/Sources/ElectrumX/NWConnecting.swift new file mode 100644 index 0000000..4094e10 --- /dev/null +++ b/Sources/ElectrumX/NWConnecting.swift @@ -0,0 +1,10 @@ +import Combine +import Network + +public protocol NWConnecting { + var connectionState: NWConnection.State { get } + var connectionStatePublisher: any Publisher { get } + + func startConnection() async throws + func cancelConnection() async throws +} diff --git a/Sources/ElectrumX/RequestID.swift b/Sources/ElectrumX/RequestID.swift new file mode 100644 index 0000000..4028620 --- /dev/null +++ b/Sources/ElectrumX/RequestID.swift @@ -0,0 +1,8 @@ +actor RequestID { + private var value = 0 + + func next() -> String { + value += 1 + return "\(value)" + } +} diff --git a/Tests/ElectrumXTests/ElectrumXServiceTests.swift b/Tests/ElectrumXTests/ElectrumXServiceTests.swift new file mode 100644 index 0000000..fc1887b --- /dev/null +++ b/Tests/ElectrumXTests/ElectrumXServiceTests.swift @@ -0,0 +1,112 @@ +import XCTest +import ElectrumX + +final class ElectrumXServiceTests: XCTestCase { + private static let versionParams = VersionParams(protocolVersion: "1.5") + private static let scriptHash = "f586a7ec7fdbfc6d6b4aa7fed034641c742c529dc86fbe8688c7313381f7ca71" + + private func sut() -> ElectrumXService { + .init( + endpoint: .hostPort(host: "electrum.nav.community", port: 40002), + parameters: .tls, + connectionTimeout: .milliseconds(500), + responseTimeout: .seconds(1) + ) + } + + func testStartConnection_AndCancelConnection() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.cancelConnection() + } + + func testPing() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.ping() + try await sut.cancelConnection() + } + + func testVersion() async throws { + let sut = sut() + try await sut.startConnection() + let response = try await sut.version(params: Self.versionParams) + try await sut.cancelConnection() + XCTAssertNotNil(response.result) + XCTAssertNil(response.error) + } + + func testHistory() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let params = HistoryParams(scriptHash: Self.scriptHash, heightRange: 0...6369450) + let response = try await sut.history(params: params) + try await sut.cancelConnection() + XCTAssertEqual(response.result?.confirmedTransactions.count, 1) + XCTAssertNil(response.error) + } + + func testBalance_WithValidScriptHash() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let response = try await sut.balance(scriptHash: Self.scriptHash) + try await sut.cancelConnection() + XCTAssertNotNil(response.result) + XCTAssertNil(response.error) + } + + func testBalance_WithInvalidScriptHash() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let response = try await sut.balance(scriptHash: "") + try await sut.cancelConnection() + XCTAssertNil(response.result) + XCTAssertNotNil(response.error) + } + + func testUnconfirmedTransactions() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let response = try await sut.unconfirmedTransactions(scriptHash: Self.scriptHash) + try await sut.cancelConnection() + XCTAssertNotNil(response.result) + XCTAssertNil(response.error) + } + + func testUnspentTransactionOutputs() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let response = try await sut.unspentTransactionOutputs(scriptHash: Self.scriptHash) + try await sut.cancelConnection() + XCTAssertNotNil(response.result) + XCTAssertNil(response.error) + } + + func testRawTransaction() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let response = try await sut.rawTransaction(hash: "6b1ccc877521306a7ab84cca371aa64b3e5107a7c06d253d5261b968b17e1165") + try await sut.cancelConnection() + XCTAssertNotEqual(response.result?.count, 0) + XCTAssertNil(response.error) + } + + func testSubscribe_AndUnsubscribe() async throws { + let sut = sut() + try await sut.startConnection() + try await sut.version(params: Self.versionParams) + let response1 = try await sut.subscribe(scriptHash: Self.scriptHash) + let response2 = try await sut.unsubscribe(scriptHash: Self.scriptHash) + try await sut.cancelConnection() + XCTAssertNotEqual(response1.result?.count, 0) + XCTAssertEqual(response2.result, true) + XCTAssertNil(response1.error) + XCTAssertNil(response2.error) + } +}