-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
19 changed files
with
545 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]) | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
public enum ElectrumXError: Error { | ||
case connectionTimeout | ||
case responseTimeout | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.