diff --git a/Package.resolved b/Package.resolved index 2b589a7..312f9f5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "revision": "0ed110f7555c34ff468e72e1686e59721f2b0da6", "version": "5.3.0" } + }, + { + "package": "Starscream", + "repositoryURL": "https://github.com/daltoniam/Starscream", + "state": { + "branch": null, + "revision": "c6bfd1af48efcc9a9ad203665db12375ba6b145a", + "version": "4.0.8" + } } ] }, diff --git a/Package.swift b/Package.swift index 634695d..1ef9e26 100644 --- a/Package.swift +++ b/Package.swift @@ -14,11 +14,12 @@ let package = Package( ], dependencies: [ .package(name: "BigInt", url: "https://github.com/attaswift/BigInt.git", from: "5.2.1"), + .package(name: "Starscream", url: "https://github.com/daltoniam/Starscream", from: "4.0.8") ], targets: [ .target( name: "Flow", - dependencies: ["BigInt"], + dependencies: ["BigInt", "Starscream"], path: "Sources", resources: [ .copy("Cadence/CommonCadence"), diff --git a/Sources/Cadence/File.swift b/Sources/Cadence/File.swift deleted file mode 100644 index 0519ecb..0000000 --- a/Sources/Cadence/File.swift +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/Sources/Extension/Publisher+Async.swift b/Sources/Extension/Publisher+Async.swift index 4ff9942..cb9bf44 100644 --- a/Sources/Extension/Publisher+Async.swift +++ b/Sources/Extension/Publisher+Async.swift @@ -5,6 +5,7 @@ */ import Combine +import Foundation @available(iOS, deprecated: 15.0, message: "AsyncCompatibilityKit is only useful when targeting iOS versions earlier than 15") public extension Publisher { @@ -62,3 +63,34 @@ public extension Publisher where Failure == Never { } } } + +struct TimeoutError: LocalizedError { + var errorDescription: String? { + return "Publisher timed out" + } +} + +public func awaitPublisher(_ publisher: T, timeout: TimeInterval = 20) async throws -> T.Output { + try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + let timeoutTask = _Concurrency.Task.detached { + try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000_000) + cancellable?.cancel() + continuation.resume(throwing: TimeoutError()) + } + + cancellable = publisher.first() + .sink( + receiveCompletion: { completion in + timeoutTask.cancel() + if case .failure(let error) = completion { + continuation.resume(throwing: error) + } + }, + receiveValue: { value in + timeoutTask.cancel() + continuation.resume(returning: value) + } + ) + } +} diff --git a/Sources/Models/FlowChainId.swift b/Sources/Models/FlowChainId.swift index dd42484..2f08909 100644 --- a/Sources/Models/FlowChainId.swift +++ b/Sources/Models/FlowChainId.swift @@ -134,6 +134,17 @@ public extension Flow { return .gRPC(.init(node: "access.mainnet.nodes.onflow.org", port: 9000)) } } + + public var defaultWebSocketNode: Flow.Transport? { + switch self { + case .mainnet: + return .websocket(URL(string: "wss://rest-mainnet.onflow.org/v1/ws")!) + case .testnet: + return .websocket(URL(string: "wss://rest-testnet.onflow.org/v1/ws")!) + default: + return nil + } + } // TODO: Support Custom Node encode & decode public func encode(to encoder: Encoder) throws { diff --git a/Sources/Network/FlowTransport.swift b/Sources/Network/FlowTransport.swift index c327a43..0d65273 100644 --- a/Sources/Network/FlowTransport.swift +++ b/Sources/Network/FlowTransport.swift @@ -21,6 +21,7 @@ public extension Flow { enum Transport: Equatable, Hashable { case HTTP(_ url: URL) case gRPC(_ endpoint: Endpoint) + case websocket(_ url: URL) public var url: URL? { switch self { @@ -28,6 +29,8 @@ public extension Flow { return url case .gRPC: return nil + case let .websocket(url): + return url } } @@ -37,6 +40,8 @@ public extension Flow { return nil case let .gRPC(endpoint): return endpoint + case .websocket(_): + return nil } } diff --git a/Sources/Network/HTTP/AnyDecodable.swift b/Sources/Network/HTTP/AnyDecodable.swift new file mode 100644 index 0000000..4eb7ec0 --- /dev/null +++ b/Sources/Network/HTTP/AnyDecodable.swift @@ -0,0 +1,40 @@ +// +// File.swift +// Flow +// +// Created by Hao Fu on 24/4/2025. +// + +import Foundation + +public struct AnyDecodable: Decodable { + public let value: Any + + public init(_ value: Any?) { + self.value = value ?? () + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.value = () + } else if let bool = try? container.decode(Bool.self) { + self.value = bool + } else if let int = try? container.decode(Int.self) { + self.value = int + } else if let uint = try? container.decode(UInt.self) { + self.value = uint + } else if let double = try? container.decode(Double.self) { + self.value = double + } else if let string = try? container.decode(String.self) { + self.value = string + } else if let array = try? container.decode([AnyDecodable].self) { + self.value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyDecodable].self) { + self.value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } +} diff --git a/Sources/Network/Websocket/FlowPublisher.swift b/Sources/Network/Websocket/FlowPublisher.swift new file mode 100644 index 0000000..1ab6550 --- /dev/null +++ b/Sources/Network/Websocket/FlowPublisher.swift @@ -0,0 +1,124 @@ +import Foundation +import Combine + +public extension Flow { + /// Represents different types of events that can be published + enum PublisherEvent { + case transactionStatus(id: Flow.ID, status: Flow.TransactionResult) + case accountUpdate(address: Flow.Address) + case connectionStatus(isConnected: Bool) + case walletResponse(approved: Bool, data: [String: Any]) + case block(id: Flow.ID, height: String, timestamp: Date) + case error(Error) + } + + /// Central publisher manager for Flow events + class Publisher { + public static let shared = Publisher() + + // Main publisher for all events + private let eventSubject = PassthroughSubject() + + // Specific publishers for different event types + public var transactionPublisher: AnyPublisher<(Flow.ID, Flow.TransactionResult), Never> { + eventSubject + .compactMap { event in + if case .transactionStatus(let id, let status) = event { + return (id, status) + } + return nil + } + .eraseToAnyPublisher() + } + + public var accountPublisher: AnyPublisher { + eventSubject + .compactMap { event in + if case .accountUpdate(let address) = event { + return address + } + return nil + } + .eraseToAnyPublisher() + } + + public var blockPublisher: AnyPublisher { + eventSubject + .compactMap { event in + if case let .block(id, height, timestamp) = event { + return WSBlockHeader(blockId: id, height: height, timestamp: timestamp) + } + return nil + } + .eraseToAnyPublisher() + } + + public var connectionPublisher: AnyPublisher { + eventSubject + .compactMap { event in + if case .connectionStatus(let isConnected) = event { + return isConnected + } + return nil + } + .eraseToAnyPublisher() + } + + public var walletResponsePublisher: AnyPublisher<(Bool, [String: Any]), Never> { + eventSubject + .compactMap { event in + if case .walletResponse(let approved, let data) = event { + return (approved, data) + } + return nil + } + .eraseToAnyPublisher() + } + + public var errorPublisher: AnyPublisher { + eventSubject + .compactMap { event in + if case .error(let error) = event { + return error + } + return nil + } + .eraseToAnyPublisher() + } + + private init() {} + + // Method to publish events + public func publish(_ event: PublisherEvent) { + eventSubject.send(event) + } + + // Convenience methods for publishing specific events + public func publishTransactionStatus(id: Flow.ID, status: Flow.TransactionResult) { + publish(.transactionStatus(id: id, status: status)) + } + + public func publishAccountUpdate(address: Flow.Address) { + publish(.accountUpdate(address: address)) + } + + public func publishConnectionStatus(isConnected: Bool) { + publish(.connectionStatus(isConnected: isConnected)) + } + + public func publishWalletResponse(approved: Bool, data: [String: Any]) { + publish(.walletResponse(approved: approved, data: data)) + } + + public func publishError(_ error: Error) { + publish(.error(error)) + } + } +} + +// Extension to Flow for easy access to publisher +public extension Flow { + var publisher: Publisher { + return Publisher.shared + } +} diff --git a/Sources/Network/Websocket/Models/WSRequest.swift b/Sources/Network/Websocket/Models/WSRequest.swift new file mode 100644 index 0000000..1a62302 --- /dev/null +++ b/Sources/Network/Websocket/Models/WSRequest.swift @@ -0,0 +1,65 @@ +// +// File.swift +// Flow +// +// Created by Hao Fu on 6/5/2025. +// + +import Foundation + +extension Flow { + public struct WSBlockHeader: Codable { + /// The identification of block + public let blockId: ID + + /// The height of block + public let height: String + + /// The time when the block is created + public let timestamp: Date + } + + public struct WSTransactionResponse: Codable { + public let transactionResult: Flow.TransactionResult + } +} + +// MARK: - Supporting Types + +extension Flow.Websocket { + enum WebSocketError: Error { + case serverError(SocketError) + } + + struct EmptyArguments: Codable {} + + struct EventArguments: Codable { + let type: String? + let contractID: String? + let address: String? + } + + public struct AccountArguments: Codable { + var startBlockId: String? = nil + var startBlockHeight: String? = nil + var heartbeatInterval: String? = nil + var eventTypes: [AccountEventType]? = nil + var accountAddresses: [String]? = nil + } + + struct SendTransactionArguments: Codable { + let transaction: Flow.Transaction + } +} + +public enum AccountEventType: String, Codable { + case accountCreated = "flow.AccountCreated" + case accountKeyAdded = "flow.AccountKeyAdded" + case accountKeyRemoved = "flow.AccountKeyRemoved" + case accountContractAdded = "flow.AccountContractAdded" + case accountContractUpdated = "flow.AccountContractUpdated" + case accountContractRemoved = "flow.AccountContractRemoved" + case inboxValuePublished = "flow.InboxValuePublished" + case inboxValueUnpublished = "flow.InboxValueUnpublished" + case inboxValueClaimed = "flow.InboxValueClaimed" +} diff --git a/Sources/Network/Websocket/WebSocketRequest.swift b/Sources/Network/Websocket/WebSocketRequest.swift new file mode 100644 index 0000000..2219bd9 --- /dev/null +++ b/Sources/Network/Websocket/WebSocketRequest.swift @@ -0,0 +1,44 @@ +// +// File.swift +// Flow +// +// Created by Hao Fu on 30/4/2025. +// + +import Foundation + +extension Flow.Websocket { + + public enum BlockStatus: String, Codable { + case finalized + case sealed + } + + struct TransactionStatusRequest: Encodable { + let txId: String + + enum CodingKeys: String, CodingKey { + case txId = "tx_id" + } + } + + struct BlockDigestArguments: Encodable { + let blockStatus: BlockStatus + let startBlockHeight: String? + let startBlockId: String? + } + + public struct AccountStatusResponse: Codable { + public let blockId: String + public let height: String + public let accountEvents: [String: [AccountStatusEvent]] + } + + public struct AccountStatusEvent: Codable { + public let type: String + public let transactionId: String + public let transactionIndex: String + public let eventIndex: String + public let payload: String + } +} diff --git a/Sources/Network/Websocket/Websocket.swift b/Sources/Network/Websocket/Websocket.swift new file mode 100644 index 0000000..bd11152 --- /dev/null +++ b/Sources/Network/Websocket/Websocket.swift @@ -0,0 +1,261 @@ +// +// File.swift +// Flow +// +// Created by Hao Fu on 29/4/2025. +// + +import Foundation +import Combine +import Starscream + +public extension Flow { + final class Websocket { + private var socket: WebSocket? + private var isConnected = false + private var subscriptions: [String: (subject: PassthroughSubject, type: Any.Type)] = [:] + private var cancellables = Set() + + private var timeoutInterval: TimeInterval = 10 + + private var decoder: JSONDecoder { + let dateFormatter = DateFormatter() + // 2022-06-22T15:32:09.08595992Z + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(dateFormatter) + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + private var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + } + + private let url: URL + + public init(url: URL, timeoutInterval: TimeInterval = 10) { + self.url = url + } + + convenience init?(chainID: Flow.ChainID, timeoutInterval: TimeInterval = 10) { + guard let node = chainID.defaultWebSocketNode, let url = node.url else { return nil } + self.init(url: url, timeoutInterval: timeoutInterval) + } + + public func connect() { + var request = URLRequest(url: url) + request.timeoutInterval = timeoutInterval + + let pinner = FoundationSecurity(allowSelfSigned: true) // do not validate SSL certificates + socket = WebSocket(request: request, certPinner: pinner) + socket?.delegate = self + socket?.connect() + } + + public func disconnect() { + socket?.disconnect() + socket = nil + isConnected = false + subscriptions.forEach { $0.value.subject.send(completion: .finished) } + subscriptions.removeAll() + cancellables.removeAll() + Flow.Publisher.shared.publishConnectionStatus(isConnected: false) + } + + // MARK: - Subscription Methods + @discardableResult + public func subscribeToBlockDigests( + blockStatus: BlockStatus = .sealed, + startBlockHeight: String? = nil, + startBlockId: String? = nil + ) -> AnyPublisher, Error> { + let arguments = BlockDigestArguments( + blockStatus: blockStatus, + startBlockHeight: startBlockHeight, + startBlockId: startBlockId + ) + return subscribe(topic: .blockDigests, arguments: arguments, type: Flow.WSBlockHeader.self) + .map { payload in + TopicResponse(subscriptionId: payload.subscriptionId, topic: payload.topic, payload: payload.payload, error: payload.error) + } + .eraseToAnyPublisher() + } + + @discardableResult + public func subscribeToBlockHeaders() -> AnyPublisher, Error> { + return subscribe(topic: .blockHeaders, arguments: EmptyArguments(), type: Flow.BlockHeader.self) + } + + @discardableResult + public func subscribeToBlocks() -> AnyPublisher, Error> { + return subscribe(topic: .blocks, arguments: EmptyArguments(), type: Flow.Block.self) + } + + @discardableResult + public func subscribeToEvents(type: String? = nil, contractID: String? = nil, address: String? = nil) -> AnyPublisher, Error> { + let arguments = EventArguments(type: type, contractID: contractID, address: address) + return subscribe(topic: .events, arguments: arguments, type: Flow.Event.self) + } + + @discardableResult + public func subscribeToAccountStatuses(request: AccountArguments) -> AnyPublisher, Error> { + let publisher = subscribe(topic: .accountStatuses, arguments: request, type: Flow.Websocket.AccountStatusResponse.self) + + // Also publish to central publisher for account updates +// Flow.Publisher.shared.publishAccountUpdate(address: Flow.Address(hex: address)) + + return publisher + } + + @discardableResult + public func subscribeToTransactionStatus(txId: Flow.ID) -> AnyPublisher, Error> { + let arguments = TransactionStatusRequest(txId: txId.hex) + let publisher = subscribe(topic: .transactionStatuses, arguments: arguments, type: Flow.WSTransactionResponse.self) + + // Also publish transaction status updates to central publisher + publisher.sink( + receiveCompletion: { _ in }, + receiveValue: { response in + if let status = response.payload { + Flow.Publisher.shared.publishTransactionStatus(id: txId, status: status.transactionResult) + } + } + ).store(in: &cancellables) + + return publisher + } + + public func listSubscriptions() { + let request = SubscribeRequest(id: generateShortUUID(), action: .listSubscriptions, topic: .blocks, arguments: nil) + do { + let data = try encoder.encode(request) + socket?.write(data: data) + } catch { + Flow.Publisher.shared.publishError(error) + } + } + + private func subscribe(topic: Topic, arguments: T, type: U.Type) -> AnyPublisher, Error> { + let subscriptionId = generateShortUUID() + let request = SubscribeRequest(id: subscriptionId, action: .subscribe, topic: topic, arguments: arguments) + let subject = PassthroughSubject() + subscriptions[subscriptionId] = (subject: subject, type: TopicResponse.self) + do { + let data = try encoder.encode(request) + socket?.write(data: data) + } catch { + subject.send(completion: .failure(error)) + subscriptions.removeValue(forKey: subscriptionId) + Flow.Publisher.shared.publishError(error) + } + return subject + .compactMap { value -> TopicResponse? in + return value as? TopicResponse + } + .eraseToAnyPublisher() + } + + public func unsubscribe(subscriptionId: String) { + let request = SubscribeRequest(id: subscriptionId, action: .unsubscribe, topic: .blocks, arguments: nil) + do { + let data = try encoder.encode(request) + socket?.write(data: data) + subscriptions[subscriptionId]?.subject.send(completion: .finished) + subscriptions.removeValue(forKey: subscriptionId) + } catch { + print("Error unsubscribing: \(error)") + Flow.Publisher.shared.publishError(error) + } + } + + // Helper method to generate short UUIDs + private func generateShortUUID() -> String { + // Generate UUID and take first 20 characters + let fullUUID = UUID().uuidString + return String(fullUUID.prefix(20)) + } + } +} + +// MARK: - WebSocketDelegate + +extension Flow.Websocket: WebSocketDelegate { + public func didReceive(event: WebSocketEvent, client: any Starscream.WebSocketClient) { + switch event { + case .connected: + isConnected = true + Flow.Publisher.shared.publishConnectionStatus(isConnected: true) + + case .disconnected(_, _): + isConnected = false + Flow.Publisher.shared.publishConnectionStatus(isConnected: false) + + case .text(let string): + handleTextMessage(string) + + case .binary(let data): + handleBinaryMessage(data) + + case .error(let error): + print("WebSocket error: \(String(describing: error))") + let wsError = WebSocketError.serverError(SocketError(code: -1, message: error?.localizedDescription ?? "Unknown error")) + subscriptions.values.forEach { $0.subject.send(completion: .failure(wsError)) } + Flow.Publisher.shared.publishError(wsError) + + default: + break + } + } + + private func handleTextMessage(_ text: String) { + guard let data = text.data(using: .utf8) else { return } + handleBinaryMessage(data) + } + + private func handleBinaryMessage(_ data: Data) { + do { + // Try to decode as a SubscribeResponse + if let response = try? decoder.decode(SubscribeResponse.self, from: data) { + if let error = response.error { + let wsError = WebSocketError.serverError(error) + subscriptions[response.subscriptionId]?.subject.send(completion: .failure(wsError)) + Flow.Publisher.shared.publishError(wsError) + } + return + } + // Try to decode as a ListSubscriptionsResponse + if let response = try? decoder.decode(ListSubscriptionsResponse.self, from: data) { + print("Active subscriptions: \(response.subscriptions)") + return + } + let object = try JSONSerialization.jsonObject(with: data) + print(object) + if let _ = try? decoder.decode(SubscribeResponse.self, from: data) { + return + } + // Directly decode using the TopicResponse.self type stored at subscription time + // First use AnyDecodable to get the subscriptionId + if let anyResponse = try? decoder.decode(TopicResponse.self, from: data), + let subscription = subscriptions[anyResponse.subscriptionId], + let decodableType = subscription.type as? Decodable.Type { + do { + let decoded = try decoder.decode(decodableType, from: data) + subscription.subject.send(decoded) + } catch { + subscription.subject.send(completion: .failure(error)) + Flow.Publisher.shared.publishError(error) + } + return + } + } catch { + print("Error decoding message: \(error)") + Flow.Publisher.shared.publishError(error) + } + } +} diff --git a/Sources/Network/Websocket/WebsocketModels.swift b/Sources/Network/Websocket/WebsocketModels.swift new file mode 100644 index 0000000..1d38bcf --- /dev/null +++ b/Sources/Network/Websocket/WebsocketModels.swift @@ -0,0 +1,69 @@ +// +// File.swift +// Flow +// +// Created by Hao Fu on 29/4/2025. +// + +import Foundation + +extension Flow.Websocket { + + public enum Action: String, Codable { + case subscribe = "subscribe" + case unsubscribe = "unsubscribe" + case listSubscriptions = "list_subscriptions" + } + + public enum Topic: String, Codable { + case blockDigests = "block_digests" + case blockHeaders = "block_headers" + case blocks = "blocks" + case events = "events" + case accountStatuses = "account_statuses" + case transactionStatuses = "transaction_statuses" + case sendAndGetTransactionStatuses = "send_and_get_transaction_statuses" + } + + public struct SubscribeRequest: Encodable { + let id: String? + let action: Action + let topic: Topic? + let arguments: T? + + enum CodingKeys: String, CodingKey { + case id = "subscription_id" + case action + case topic + case arguments + } + } + + public struct SubscribeResponse: Decodable { + let subscriptionId: String + let action: Action + let error: SocketError? + } + + public struct SocketError: Codable { + let code: Int + let message: String + } + + public struct TopicResponse: Decodable { + let subscriptionId: String + let topic: Topic + let payload: T? + let error: SocketError? + } + + public struct ListSubscriptionsResponse: Decodable { + let subscriptions: [SubscriptionInfo] + } + + public struct SubscriptionInfo: Decodable { + let id: String + let topic: Topic + let arguments: AnyDecodable? + } +} \ No newline at end of file diff --git a/Tests/FlowAccessAPIOnMainnetTests.swift b/Tests/FlowAccessAPIOnMainnetTests.swift index 8aa2e9b..f226fac 100644 --- a/Tests/FlowAccessAPIOnMainnetTests.swift +++ b/Tests/FlowAccessAPIOnMainnetTests.swift @@ -47,9 +47,11 @@ final class FlowAccessAPIOnMainnetTests: XCTestCase { } func testGetAccount() async throws { - let account = try await flowAPI.getAccountAtLatestBlock(address: address) + let account = try await flowAPI.getAccountAtLatestBlock(address: .init(hex: "0x84221fe0294044d7")) XCTAssertNotNil(account.keys.first) - XCTAssertEqual(address, account.address) +// XCTAssertEqual(address, account.address) + XCTAssertEqual(754, account.keys.first!.sequenceNumber) + XCTAssertEqual(1000, account.keys.first!.weight) } func testGetAccount2() async throws { @@ -235,13 +237,26 @@ final class FlowAccessAPIOnMainnetTests: XCTestCase { } func testTransactionById() async throws { - let id = Flow.ID(hex: "6d6c20405f3dd2001361cd994493a56d31f4daa1c7ce420a2cd4259454b4a0da") - let transaction = try await flowAPI.getTransactionById(id: id) - XCTAssertEqual(transaction.arguments.first?.type, .path) - XCTAssertEqual(transaction.arguments.first?.value, .path(.init(domain: "public", identifier: "zelosAccountingTokenReceiver"))) - XCTAssertEqual(transaction.arguments.last?.type, .ufix64) - XCTAssertEqual(transaction.arguments.last?.value.toUFix64(), 99.0) - XCTAssertEqual(transaction.payer.bytes.hexValue, "1f56a1e665826a52") - XCTAssertNotNil(transaction) + let id = Flow.ID(hex: "40b18af87cbd776b934203583a89700a3f9e22c062510a04db386e9d18355b7c") + let transaction = try await flowAPI.getTransactionResultById(id: id) +// let event = transaction.getEvent("flow.AccountCreated") +// let address: String? = event?.getField("address") +// let address = transaction.getCreateAddress() +// print(address) +// let event = transaction.events.filter { $0.type == "flow.AccountCreated" }.first +// let field = event?.payload.fields?.value.toEvent()?.fields.first{$0.name == "address"} + +// if case let .event(eve) = event?.payload.fields?.value { +// print(eve.id) +// } + +// let address = field?.value.value.toAddress()?.hex +// print(event) +// XCTAssertEqual(transaction.arguments.first?.type, .path) +// XCTAssertEqual(transaction.arguments.first?.value, .path(.init(domain: "public", identifier: "zelosAccountingTokenReceiver"))) +// XCTAssertEqual(transaction.arguments.last?.type, .ufix64) +// XCTAssertEqual(transaction.arguments.last?.value.toUFix64(), 99.0) +// XCTAssertEqual(transaction.payer.bytes.hexValue, "1f56a1e665826a52") +// XCTAssertNotNil(transaction) } } diff --git a/Tests/FlowTests/PublisherTests.swift b/Tests/FlowTests/PublisherTests.swift new file mode 100644 index 0000000..00eb9e2 --- /dev/null +++ b/Tests/FlowTests/PublisherTests.swift @@ -0,0 +1,158 @@ +import XCTest +import Combine +@testable import Flow + +final class PublisherTests: XCTestCase { + private var cancellables: Set! + + override func setUp() { + super.setUp() + cancellables = [] + } + + override func tearDown() { + cancellables.removeAll() + super.tearDown() + } + + // MARK: - Transaction Status Tests + + func testTransactionStatusPublishing() { +// let expectation = self.expectation(description: "Transaction status") +// let testId = Flow.ID(hex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") +// let testStatus = Flow.Transaction.Status.sealed +// var receivedId: Flow.ID? +// var receivedStatus: Flow.Transaction.Status? +// +// Flow.Publisher.shared.transactionPublisher +// .sink { id, status in +// receivedId = id +// receivedStatus = status +// expectation.fulfill() +// } +// .store(in: &cancellables) +// +// Flow.Publisher.shared.publishTransactionStatus(id: testId, status: testStatus) +// +// waitForExpectations(timeout: 1) +// XCTAssertEqual(receivedId, testId) +// XCTAssertEqual(receivedStatus, testStatus) + } + + // MARK: - Account Update Tests + + func testAccountUpdatePublishing() { + let expectation = self.expectation(description: "Account update") + let testAddress = Flow.Address(hex: "0x0123456789abcdef") + var receivedAddress: Flow.Address? + + Flow.Publisher.shared.accountPublisher + .sink { address in + receivedAddress = address + expectation.fulfill() + } + .store(in: &cancellables) + + Flow.Publisher.shared.publishAccountUpdate(address: testAddress) + + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedAddress, testAddress) + } + + // MARK: - Connection Status Tests + + func testConnectionStatusPublishing() { + let expectation = self.expectation(description: "Connection status") + let testStatus = true + var receivedStatus: Bool? + + Flow.Publisher.shared.connectionPublisher + .sink { status in + receivedStatus = status + expectation.fulfill() + } + .store(in: &cancellables) + + Flow.Publisher.shared.publishConnectionStatus(isConnected: testStatus) + + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedStatus, testStatus) + } + + // MARK: - Wallet Response Tests + + func testWalletResponsePublishing() { + let expectation = self.expectation(description: "Wallet response") + let testApproved = true + let testData: [String: Any] = ["key": "value"] + var receivedApproved: Bool? + var receivedData: [String: Any]? + + Flow.Publisher.shared.walletResponsePublisher + .sink { approved, data in + receivedApproved = approved + receivedData = data + expectation.fulfill() + } + .store(in: &cancellables) + + Flow.Publisher.shared.publishWalletResponse(approved: testApproved, data: testData) + + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedApproved, testApproved) + XCTAssertEqual(receivedData?["key"] as? String, testData["key"] as? String) + } + + // MARK: - Error Tests + + func testErrorPublishing() { + let expectation = self.expectation(description: "Error") + let testError = NSError(domain: "test", code: 1, userInfo: nil) + var receivedError: Error? + + Flow.Publisher.shared.errorPublisher + .sink { error in + receivedError = error + expectation.fulfill() + } + .store(in: &cancellables) + + Flow.Publisher.shared.publishError(testError) + + waitForExpectations(timeout: 1) + XCTAssertEqual((receivedError as NSError?)?.domain, testError.domain) + XCTAssertEqual((receivedError as NSError?)?.code, testError.code) + } + + // MARK: - Multiple Subscriber Tests + + func testMultipleSubscribers() { + let expectation1 = self.expectation(description: "Subscriber 1") + let expectation2 = self.expectation(description: "Subscriber 2") + let testStatus = true + var receivedStatus1: Bool? + var receivedStatus2: Bool? + + // First subscriber + Flow.Publisher.shared.connectionPublisher + .sink { status in + receivedStatus1 = status + expectation1.fulfill() + } + .store(in: &cancellables) + + // Second subscriber + Flow.Publisher.shared.connectionPublisher + .sink { status in + receivedStatus2 = status + expectation2.fulfill() + } + .store(in: &cancellables) + + Flow.Publisher.shared.publishConnectionStatus(isConnected: testStatus) + + waitForExpectations(timeout: 1) + XCTAssertEqual(receivedStatus1, testStatus) + XCTAssertEqual(receivedStatus2, testStatus) + } +} diff --git a/Tests/FlowTests/WebSocketTests.swift b/Tests/FlowTests/WebSocketTests.swift new file mode 100644 index 0000000..48535a9 --- /dev/null +++ b/Tests/FlowTests/WebSocketTests.swift @@ -0,0 +1,83 @@ +import XCTest +import Combine +@testable import Flow + +final class WebSocketTests: XCTestCase { + private var cancellables = Set() + private var websocket: Flow.Websocket! + + override func setUp() { + super.setUp() + websocket = Flow.Websocket(chainID: .mainnet) + websocket.connect() + } + + override func tearDown() { + websocket.disconnect() + cancellables.removeAll() + super.tearDown() + } + + func awaitConnection() async throws { + let result = try await awaitPublisher( + Flow.Publisher.shared.connectionPublisher + .filter { $0 == true } + .first() + ) + + print(result) + } + + func testWebSocketConnection() async throws { + try await awaitConnection() + } + + func testWebSocketDisconnection() async throws { + try await awaitConnection() + Flow.Publisher.shared.connectionPublisher + .sink(receiveValue: { connect in + print(connect) + }) + websocket.disconnect() + +// XCTAssertEqual(connect, false) + } + + func testBlockDigestSubscription() async throws { + try await awaitConnection() + let blockHeader = try await awaitPublisher( + websocket.subscribeToBlockDigests() + ) + XCTAssertNotNil(blockHeader) + } + + func testTransactionStatusSubscription() async throws { + try await awaitConnection() + let testTxId = "5ab8b0bec5ee89c63c5c33ddc4144f3772d0eeda0e85e905fc7e41c2d449269f" + websocket.subscribeToTransactionStatus(txId: .init(hex: testTxId)) + let status = try await awaitPublisher( + flow.publisher.transactionPublisher + .filter({ $0.1.status > .executed }) + .eraseToAnyPublisher() + ) + + print(status) + XCTAssertNotNil(status) + websocket.disconnect() + } + + func testAccountStatusSubscription() async throws { + try await awaitConnection() + + let testAddress = "0x418c09f201f67f89" + let account = try await awaitPublisher( + websocket.subscribeToAccountStatuses(request: .init(heartbeatInterval: "10", accountAddresses: [testAddress])) + ) + XCTAssertNotNil(account) + websocket.disconnect() + } + + func testListSubscriptions() async throws { + try await awaitConnection() + } +}