Skip to content

Commit

Permalink
Added sources and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
anquii committed Aug 5, 2023
1 parent caf14cf commit 33a302f
Show file tree
Hide file tree
Showing 19 changed files with 545 additions and 5 deletions.
14 changes: 14 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 21 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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"])
]
)
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions Sources/ElectrumX/ElectrumX.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Combine
import JSONRPC2

public protocol ElectrumX {
var scriptHashNotificationPublisher: any Publisher<ScriptHashNotification, Never> { get }

func ping() async throws
func version(params: VersionParams) async throws -> JSONRPC2Response<Version, JSONRPC2Error>
func history(params: HistoryParams) async throws -> JSONRPC2Response<History, JSONRPC2Error>
func balance(scriptHash: String) async throws -> JSONRPC2Response<Balance, JSONRPC2Error>
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<String, JSONRPC2Error>
/// Returns the hash of the broadcasted transaction.
func broadcast(rawTransaction: String) async throws -> JSONRPC2Response<String, JSONRPC2Error>
/// Returns the status of the script hash.
func subscribe(scriptHash: String) async throws -> JSONRPC2Response<String, JSONRPC2Error>
/// Returns a bool to specify if the script hash was subscribed to.
func unsubscribe(scriptHash: String) async throws -> JSONRPC2Response<Bool, JSONRPC2Error>
}
4 changes: 4 additions & 0 deletions Sources/ElectrumX/ElectrumXError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
public enum ElectrumXError: Error {
case connectionTimeout
case responseTimeout
}
209 changes: 209 additions & 0 deletions Sources/ElectrumX/ElectrumXService.swift
Original file line number Diff line number Diff line change
@@ -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<Data, ElectrumXError>()
private let connectionStateWithErrorPublisher = PassthroughSubject<NWConnection.State, ElectrumXError>()
private let _connectionStatePublisher = PassthroughSubject<NWConnection.State, Never>()
private let _scriptHashNotificationPublisher = PassthroughSubject<ScriptHashNotification, Never>()
private var cancellables = Set<AnyCancellable>()

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<NWConnection.State, Never> {
_connectionStatePublisher.eraseToAnyPublisher()
}

public func startConnection() async throws {
guard connectionState != .ready else {
return
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
let receiveCompletion = { (completion: Subscribers.Completion<ElectrumXError>) 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<ScriptHashNotification, Never> {
_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<Version, JSONRPC2Error> {
try await sendMessageAndReceiveResponse(method: "server.version", params: params)
}

public func history(params: HistoryParams) async throws -> JSONRPC2Response<History, JSONRPC2Error> {
try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.get_history", params: params)
}

public func balance(scriptHash: String) async throws -> JSONRPC2Response<Balance, JSONRPC2Error> {
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<String, JSONRPC2Error> {
try await sendMessageAndReceiveResponse(method: "blockchain.transaction.get", params: [hash])
}

public func broadcast(rawTransaction: String) async throws -> JSONRPC2Response<String, JSONRPC2Error> {
try await sendMessageAndReceiveResponse(method: "blockchain.transaction.broadcast", params: [rawTransaction])
}

public func subscribe(scriptHash: String) async throws -> JSONRPC2Response<String, JSONRPC2Error> {
try await sendMessageAndReceiveResponse(method: "blockchain.scripthash.subscribe", params: [scriptHash])
}

public func unsubscribe(scriptHash: String) async throws -> JSONRPC2Response<Bool, JSONRPC2Error> {
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<ScriptHashNotification>.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<Void, Error>) in
connection.send(content: data, contentContext: contentContext, completion: .contentProcessed { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
})
}
}

func receiveResponse<R: Decodable, E: Decodable>(requestID: String) async throws -> JSONRPC2Response<R, E> {
try await withCheckedThrowingContinuation { continuation in
let receiveCompletion = { (completion: Subscribers.Completion<Error>) in
if case .failure(let error) = completion {
continuation.resume(throwing: error)
}
}
let receiveValue = { (value: JSONRPC2Response<R, E>) in
continuation.resume(returning: value)
}
dataPublisher
.timeout(responseTimeout, scheduler: queue, customError: { .responseTimeout })
.decode(type: JSONRPC2Response<R, E>.self, decoder: decoder)
.first { $0.id == requestID && $0.jsonrpc == "2.0" }
.sink(receiveCompletion: receiveCompletion, receiveValue: receiveValue)
.store(in: &cancellables)
}
}

func sendMessageAndReceiveResponse<P: Encodable, R: Decodable, E: Decodable>(method: String, params: P) async throws -> JSONRPC2Response<R, E> {
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)
}
}
4 changes: 4 additions & 0 deletions Sources/ElectrumX/Entities/Balance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
public struct Balance: Decodable {
public let confirmed: UInt64
public let unconfirmed: UInt64
}
15 changes: 15 additions & 0 deletions Sources/ElectrumX/Entities/ConfirmedTransaction.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
18 changes: 18 additions & 0 deletions Sources/ElectrumX/Entities/History.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
public struct History: Decodable {
public let heightRange: ClosedRange<UInt64>
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"
}
}
16 changes: 16 additions & 0 deletions Sources/ElectrumX/Entities/HistoryParams.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
public struct HistoryParams: Encodable {
public let scriptHash: String
public let heightRange: ClosedRange<UInt64>

public init(scriptHash: String, heightRange: ClosedRange<UInt64>) {
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)
}
}
10 changes: 10 additions & 0 deletions Sources/ElectrumX/Entities/ScriptHashNotification.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 33a302f

Please sign in to comment.