diff --git a/.github/workflows/intake.yml b/.github/workflows/intake.yml new file mode 100644 index 000000000..4b79bc326 --- /dev/null +++ b/.github/workflows/intake.yml @@ -0,0 +1,43 @@ +# This workflow moves issues to the Swift board +# when they receive the "accepted" label +# When WalletConnect Org members create issues they +# are automatically "accepted". +# Else they need to manually receive that label during intake. +name: intake + +on: + issues: + types: [opened, labeled] + pull_request: + types: [opened, labeled] + +jobs: + add-to-project: + name: Add issue to board + if: github.event.action == 'labeled' && github.event.label.name == 'accepted' + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v0.1.0 + with: + project-url: https://github.com/orgs/WalletConnect/projects/5 + github-token: ${{ secrets.ASSIGN_TO_PROJECT_GITHUB_TOKEN }} + labeled: accepted + label-operator: OR + auto-promote: + name: auto-promote + if: github.event.action == 'opened' + runs-on: ubuntu-latest + steps: + - name: Check if organization member + id: is_organization_member + if: github.event.action == 'opened' + uses: JamesSingleton/is-organization-member@1.0.0 + with: + organization: WalletConnect + username: ${{ github.event_name != 'pull_request' && github.event.issue.user.login || github.event.sender.login }} + token: ${{ secrets.ASSIGN_TO_PROJECT_GITHUB_TOKEN }} + - name: Label issues + uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90 + with: + add-labels: "accepted" + repo-token: ${{ secrets.ASSIGN_TO_PROJECT_GITHUB_TOKEN }} diff --git a/Package.swift b/Package.swift index f58a2bc4f..14aa4884f 100644 --- a/Package.swift +++ b/Package.swift @@ -47,7 +47,7 @@ let package = Package( dependencies: ["WalletConnectUtils"]), .target( name: "WalletConnectUtils", - dependencies: ["Commons"]), + dependencies: ["Commons", "JSONRPC"]), .target( name: "JSONRPC", dependencies: ["Commons"]), @@ -71,11 +71,11 @@ let package = Package( dependencies: ["WalletConnectKMS", "WalletConnectUtils", "TestingUtils"]), .target( name: "TestingUtils", - dependencies: ["WalletConnectUtils", "WalletConnectKMS"], + dependencies: ["WalletConnectUtils", "WalletConnectKMS", "JSONRPC"], path: "Tests/TestingUtils"), .testTarget( name: "WalletConnectUtilsTests", - dependencies: ["WalletConnectUtils"]), + dependencies: ["WalletConnectUtils", "TestingUtils"]), .testTarget( name: "JSONRPCTests", dependencies: ["JSONRPC", "TestingUtils"]), diff --git a/Sources/Commons/Either.swift b/Sources/Commons/Either.swift index 71e7b8dde..3c5f1fbdf 100644 --- a/Sources/Commons/Either.swift +++ b/Sources/Commons/Either.swift @@ -63,3 +63,15 @@ extension Either: Codable where L: Codable, R: Codable { } } } + +extension Either: CustomStringConvertible { + + public var description: String { + switch self { + case let .left(left): + return "\(left)" + case let .right(right): + return "\(right)" + } + } +} diff --git a/Sources/JSONRPC/RPCRequest.swift b/Sources/JSONRPC/RPCRequest.swift index 473550899..ad0bfed2b 100644 --- a/Sources/JSONRPC/RPCRequest.swift +++ b/Sources/JSONRPC/RPCRequest.swift @@ -72,11 +72,11 @@ public struct RPCRequest: Equatable { extension RPCRequest { - static func notification(method: String, params: C) -> RPCRequest where C: Codable { + public static func notification(method: String, params: C) -> RPCRequest where C: Codable { return RPCRequest(method: method, params: AnyCodable(params), id: nil) } - static func notification(method: String) -> RPCRequest { + public static func notification(method: String) -> RPCRequest { return RPCRequest(method: method, params: nil, id: nil) } diff --git a/Sources/JSONRPC/RPCResponse.swift b/Sources/JSONRPC/RPCResponse.swift index 0934adb7e..d09055542 100644 --- a/Sources/JSONRPC/RPCResponse.swift +++ b/Sources/JSONRPC/RPCResponse.swift @@ -19,7 +19,7 @@ public struct RPCResponse: Equatable { return nil } - private let outcome: Result + public let outcome: Result internal init(id: RPCID?, outcome: Result) { self.jsonrpc = "2.0" @@ -27,6 +27,14 @@ public struct RPCResponse: Equatable { self.outcome = outcome } + public init(matchingRequest: RPCRequest, result: C) where C: Codable { + self.init(id: matchingRequest.id, outcome: .success(AnyCodable(result))) + } + + public init(matchingRequest: RPCRequest, error: JSONRPCError) { + self.init(id: matchingRequest.id, outcome: .failure(error)) + } + public init(id: Int, result: C) where C: Codable { self.init(id: RPCID(id), outcome: .success(AnyCodable(result))) } diff --git a/Sources/WalletConnectRelay/RPC/Methods.swift b/Sources/WalletConnectRelay/RPC/Methods.swift new file mode 100644 index 000000000..be94ac20e --- /dev/null +++ b/Sources/WalletConnectRelay/RPC/Methods.swift @@ -0,0 +1,65 @@ +struct Subscribe: RelayRPC { + + struct Params: Codable { + let topic: String + } + + let params: Params + + var method: String { + "subscribe" + } +} + +struct Unsubscribe: RelayRPC { + + struct Params: Codable { + let id: String + let topic: String + } + + let params: Params + + var method: String { + "unsubscribe" + } +} + +struct Publish: RelayRPC { + + struct Params: Codable { + let topic: String + let message: String + let ttl: Int + let prompt: Bool? + let tag: Int? + } + + let params: Params + + var method: String { + "publish" + } +} + +struct Subscription: RelayRPC { + + struct Params: Codable { + struct Contents: Codable { + let topic: String + let message: String + } + let id: String + let data: Contents + } + + let params: Params + + var method: String { + "subscription" + } + + init(id: String, topic: String, message: String) { + self.params = Params(id: id, data: Params.Contents(topic: topic, message: message)) + } +} diff --git a/Sources/WalletConnectRelay/RPC/RPCMethod.swift b/Sources/WalletConnectRelay/RPC/RPCMethod.swift new file mode 100644 index 000000000..1f104f7c7 --- /dev/null +++ b/Sources/WalletConnectRelay/RPC/RPCMethod.swift @@ -0,0 +1,5 @@ +protocol RPCMethod { + associatedtype Parameters + var method: String { get } + var params: Parameters { get } +} diff --git a/Sources/WalletConnectRelay/RPC/RelayRPC.swift b/Sources/WalletConnectRelay/RPC/RelayRPC.swift new file mode 100644 index 000000000..a8f3230a7 --- /dev/null +++ b/Sources/WalletConnectRelay/RPC/RelayRPC.swift @@ -0,0 +1,38 @@ +import JSONRPC + +protocol RelayRPC: RPCMethod {} + +extension RelayRPC where Parameters: Codable { + + var idGenerator: IdentifierGenerator { + return WalletConnectRPCID() + } + + func wrapToIridium() -> PrefixDecorator { + return PrefixDecorator(rpcMethod: self, prefix: "iridium") + } + + func wrapToIRN() -> PrefixDecorator { + return PrefixDecorator(rpcMethod: self, prefix: "irn") + } + + func asRPCRequest() -> RPCRequest { + RPCRequest(method: self.method, params: self.params, idGenerator: self.idGenerator) + } +} + +struct PrefixDecorator: RelayRPC where T: RelayRPC { + + typealias Parameters = T.Parameters + + let rpcMethod: T + let prefix: String + + var method: String { + "\(prefix)_\(rpcMethod.method)" + } + + var params: Parameters { + rpcMethod.params + } +} diff --git a/Sources/WalletConnectRelay/RPC/WalletConnectRPCID.swift b/Sources/WalletConnectRelay/RPC/WalletConnectRPCID.swift new file mode 100644 index 000000000..956b28868 --- /dev/null +++ b/Sources/WalletConnectRelay/RPC/WalletConnectRPCID.swift @@ -0,0 +1,11 @@ +import Foundation +import JSONRPC + +struct WalletConnectRPCID: IdentifierGenerator { + + func next() -> RPCID { + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) * 1000 + let random = Int64.random(in: 0..<1000) + return .right(Int(timestamp + random)) + } +} diff --git a/Sources/WalletConnectRelay/RelayClient.swift b/Sources/WalletConnectRelay/RelayClient.swift index 47dc706b8..94815d407 100644 --- a/Sources/WalletConnectRelay/RelayClient.swift +++ b/Sources/WalletConnectRelay/RelayClient.swift @@ -2,41 +2,45 @@ import Foundation import Combine import WalletConnectUtils import WalletConnectKMS +import JSONRPC public enum SocketConnectionStatus { case connected case disconnected } + public final class RelayClient { - enum RelyerError: Error { + + enum Errors: Error { case subscriptionIdNotFound } - private typealias SubscriptionRequest = JSONRPCRequest - private typealias SubscriptionResponse = JSONRPCResponse - private typealias RequestAcknowledgement = JSONRPCResponse - private let concurrentQueue = DispatchQueue(label: "com.walletconnect.sdk.relay_client", - attributes: .concurrent) - let jsonRpcSubscriptionsHistory: JsonRpcHistory + + static let historyIdentifier = "com.walletconnect.sdk.relayer_client.subscription_json_rpc_record" + public var onMessage: ((String, String) -> Void)? - private var dispatcher: Dispatching - var subscriptions: [String: String] = [:] + let defaultTtl = 6*Time.hour + var subscriptions: [String: String] = [:] public var socketConnectionStatusPublisher: AnyPublisher { socketConnectionStatusPublisherSubject.eraseToAnyPublisher() } private let socketConnectionStatusPublisherSubject = PassthroughSubject() - private var subscriptionResponsePublisher: AnyPublisher, Never> { + private let subscriptionResponsePublisherSubject = PassthroughSubject<(RPCID?, String), Never>() + private var subscriptionResponsePublisher: AnyPublisher<(RPCID?, String), Never> { subscriptionResponsePublisherSubject.eraseToAnyPublisher() } - private let subscriptionResponsePublisherSubject = PassthroughSubject, Never>() - private var requestAcknowledgePublisher: AnyPublisher, Never> { + private let requestAcknowledgePublisherSubject = PassthroughSubject() + private var requestAcknowledgePublisher: AnyPublisher { requestAcknowledgePublisherSubject.eraseToAnyPublisher() } - private let requestAcknowledgePublisherSubject = PassthroughSubject, Never>() - let logger: ConsoleLogging - static let historyIdentifier = "com.walletconnect.sdk.relayer_client.subscription_json_rpc_record" + + private var dispatcher: Dispatching + private let rpcHistory: RPCHistory + private let logger: ConsoleLogging + + private let concurrentQueue = DispatchQueue(label: "com.walletconnect.sdk.relay_client", attributes: .concurrent) init( dispatcher: Dispatching, @@ -45,14 +49,22 @@ public final class RelayClient { ) { self.logger = logger self.dispatcher = dispatcher - - self.jsonRpcSubscriptionsHistory = JsonRpcHistory(logger: logger, keyValueStore: CodableStore(defaults: keyValueStorage, identifier: Self.historyIdentifier)) + self.rpcHistory = RPCHistory(keyValueStore: CodableStore(defaults: keyValueStorage, identifier: Self.historyIdentifier)) setUpBindings() } + private func setUpBindings() { + dispatcher.onMessage = { [weak self] payload in + self?.handlePayloadMessage(payload) + } + dispatcher.onConnect = { [unowned self] in + self.socketConnectionStatusPublisherSubject.send(.connected) + } + } + /// Instantiates Relay Client /// - Parameters: - /// - relayHost: proxy server host that your application will use to connect to Iridium Network. If you register your project at `www.walletconnect.com` you can use `relay.walletconnect.com` + /// - relayHost: proxy server host that your application will use to connect to Relay Network. If you register your project at `www.walletconnect.com` you can use `relay.walletconnect.com` /// - projectId: an optional parameter used to access the public WalletConnect infrastructure. Go to `www.walletconnect.com` for info. /// - keyValueStorage: by default WalletConnect SDK will store sequences in UserDefaults /// - socketConnectionType: socket connection type @@ -98,65 +110,69 @@ public final class RelayClient { /// Completes when networking client sends a request, error if it fails on client side public func publish(topic: String, payload: String, tag: Int, prompt: Bool = false) async throws { - let params = RelayJSONRPC.PublishParams(topic: topic, message: payload, ttl: defaultTtl, prompt: prompt, tag: tag) - let request = JSONRPCRequest(method: RelayJSONRPC.Method.publish.method, params: params) - logger.debug("Publishing Payload on Topic: \(topic)") - let requestJson = try request.json() - try await dispatcher.send(requestJson) + let request = Publish(params: .init(topic: topic, message: payload, ttl: defaultTtl, prompt: prompt, tag: tag)) + .wrapToIridium() + .asRPCRequest() + let message = try request.asJSONEncodedString() + logger.debug("Publishing payload on topic: \(topic)") + try await dispatcher.send(message) } /// Completes with an acknowledgement from the relay network. - @discardableResult public func publish( + public func publish( topic: String, payload: String, tag: Int, prompt: Bool = false, - onNetworkAcknowledge: @escaping ((Error?) -> Void)) -> Int64 { - let params = RelayJSONRPC.PublishParams(topic: topic, message: payload, ttl: defaultTtl, prompt: prompt, tag: tag) - let request = JSONRPCRequest(method: RelayJSONRPC.Method.publish.method, params: params) - let requestJson = try! request.json() - logger.debug("iridium: Publishing Payload on Topic: \(topic)") + onNetworkAcknowledge: @escaping ((Error?) -> Void) + ) { + let rpc = Publish(params: .init(topic: topic, message: payload, ttl: defaultTtl, prompt: prompt, tag: tag)) + let request = rpc + .wrapToIridium() + .asRPCRequest() + let message = try! request.asJSONEncodedString() + logger.debug("Publishing Payload on Topic: \(topic)") var cancellable: AnyCancellable? - dispatcher.send(requestJson) { [weak self] error in + cancellable = requestAcknowledgePublisher + .filter { $0 == request.id } + .sink { (_) in + cancellable?.cancel() + onNetworkAcknowledge(nil) + } + dispatcher.send(message) { [weak self] error in if let error = error { self?.logger.debug("Failed to Publish Payload, error: \(error)") cancellable?.cancel() onNetworkAcknowledge(error) } } - cancellable = requestAcknowledgePublisher - .filter {$0.id == request.id} - .sink { (_) in - cancellable?.cancel() - onNetworkAcknowledge(nil) - } - return request.id } @available(*, renamed: "subscribe(topic:)") public func subscribe(topic: String, completion: @escaping (Error?) -> Void) { - logger.debug("iridium: Subscribing on Topic: \(topic)") - let params = RelayJSONRPC.SubscribeParams(topic: topic) - let request = JSONRPCRequest(method: RelayJSONRPC.Method.subscribe.method, params: params) - let requestJson = try! request.json() + logger.debug("Relay: Subscribing to topic: \(topic)") + let rpc = Subscribe(params: .init(topic: topic)) + let request = rpc + .wrapToIridium() + .asRPCRequest() + let message = try! request.asJSONEncodedString() var cancellable: AnyCancellable? - dispatcher.send(requestJson) { [weak self] error in + cancellable = subscriptionResponsePublisher + .filter { $0.0 == request.id } + .sink { [weak self] subscriptionInfo in + cancellable?.cancel() + self?.concurrentQueue.async(flags: .barrier) { + self?.subscriptions[topic] = subscriptionInfo.1 + } + completion(nil) + } + dispatcher.send(message) { [weak self] error in if let error = error { - self?.logger.debug("Failed to Subscribe on Topic \(error)") + self?.logger.debug("Failed to subscribe to topic \(error)") cancellable?.cancel() completion(error) - } else { - completion(nil) } } - cancellable = subscriptionResponsePublisher - .filter {$0.id == request.id} - .sink { [weak self] (subscriptionResponse) in - cancellable?.cancel() - self?.concurrentQueue.async(flags: .barrier) { - self?.subscriptions[topic] = subscriptionResponse.result - } - } } public func subscribe(topic: String) async throws { @@ -171,20 +187,28 @@ public final class RelayClient { } } - @discardableResult public func unsubscribe(topic: String, completion: @escaping ((Error?) -> Void)) -> Int64? { + public func unsubscribe(topic: String, completion: @escaping ((Error?) -> Void)) { guard let subscriptionId = subscriptions[topic] else { - completion(RelyerError.subscriptionIdNotFound) - return nil + completion(Errors.subscriptionIdNotFound) + return } - logger.debug("iridium: Unsubscribing on Topic: \(topic)") - let params = RelayJSONRPC.UnsubscribeParams(id: subscriptionId, topic: topic) - let request = JSONRPCRequest(method: RelayJSONRPC.Method.unsubscribe.method, params: params) - let requestJson = try! request.json() + logger.debug("Relay: Unsubscribing from topic: \(topic)") + let rpc = Unsubscribe(params: .init(id: subscriptionId, topic: topic)) + let request = rpc + .wrapToIridium() + .asRPCRequest() + let message = try! request.asJSONEncodedString() + rpcHistory.deleteAll(forTopic: topic) var cancellable: AnyCancellable? - jsonRpcSubscriptionsHistory.delete(topic: topic) - dispatcher.send(requestJson) { [weak self] error in + cancellable = requestAcknowledgePublisher + .filter { $0 == request.id } + .sink { (_) in + cancellable?.cancel() + completion(nil) + } + dispatcher.send(message) { [weak self] error in if let error = error { - self?.logger.debug("Failed to Unsubscribe on Topic") + self?.logger.debug("Failed to unsubscribe from topic") cancellable?.cancel() completion(error) } else { @@ -194,48 +218,38 @@ public final class RelayClient { completion(nil) } } - cancellable = requestAcknowledgePublisher - .filter {$0.id == request.id} - .sink { (_) in - cancellable?.cancel() - completion(nil) - } - return request.id - } - - private func setUpBindings() { - dispatcher.onMessage = { [weak self] payload in - self?.handlePayloadMessage(payload) - } - dispatcher.onConnect = { [unowned self] in - self.socketConnectionStatusPublisherSubject.send(.connected) - } } + // FIXME: Parse data to string once before trying to decode -> respond error on fail private func handlePayloadMessage(_ payload: String) { - if let request = tryDecode(SubscriptionRequest.self, from: payload), validate(request: request, method: .subscription) { - do { - try jsonRpcSubscriptionsHistory.set(topic: request.params.data.topic, request: request) - onMessage?(request.params.data.topic, request.params.data.message) - acknowledgeSubscription(requestId: request.id) - } catch { - logger.info("Relay Client Info: Json Rpc Duplicate Detected") + if let request = tryDecode(RPCRequest.self, from: payload) { + if let params = try? request.params?.get(Subscription.Params.self) { + do { + try rpcHistory.set(request, forTopic: params.data.topic, emmitedBy: .remote) + try acknowledgeRequest(request) + onMessage?(params.data.topic, params.data.message) + } catch { + logger.error("[RelayClient] RPC History 'set()' error: \(error)") + } + } else { + logger.error("Unexpected request from network") + } + } else if let response = tryDecode(RPCResponse.self, from: payload) { + switch response.outcome { + case .success(let anyCodable): + if let _ = try? anyCodable.get(Bool.self) { // TODO: Handle success vs. error + requestAcknowledgePublisherSubject.send(response.id) + } else if let subscriptionId = try? anyCodable.get(String.self) { + subscriptionResponsePublisherSubject.send((response.id, subscriptionId)) + } + case .failure(let rpcError): + logger.error("Received RPC error from relay network: \(rpcError)") } - } else if let response = tryDecode(RequestAcknowledgement.self, from: payload) { - requestAcknowledgePublisherSubject.send(response) - } else if let response = tryDecode(SubscriptionResponse.self, from: payload) { - subscriptionResponsePublisherSubject.send(response) - } else if let response = tryDecode(JSONRPCErrorResponse.self, from: payload) { - logger.error("Received error message from iridium network, code: \(response.error.code), message: \(response.error.message)") } else { logger.error("Unexpected response from network") } } - private func validate(request: JSONRPCRequest, method: RelayJSONRPC.Method) -> Bool { - return request.method.contains(method.name) - } - private func tryDecode(_ type: T.Type, from payload: String) -> T? { if let data = payload.data(using: .utf8), let response = try? JSONDecoder().decode(T.self, from: data) { @@ -245,13 +259,13 @@ public final class RelayClient { } } - private func acknowledgeSubscription(requestId: Int64) { - let response = JSONRPCResponse(id: requestId, result: AnyCodable(true)) - let responseJson = try! response.json() - _ = try? jsonRpcSubscriptionsHistory.resolve(response: JsonRpcResult.response(response)) - dispatcher.send(responseJson) { [weak self] error in - if let error = error { - self?.logger.debug("Failed to Respond for request id: \(requestId), error: \(error)") + private func acknowledgeRequest(_ request: RPCRequest) throws { + let response = RPCResponse(matchingRequest: request, result: true) + try rpcHistory.resolve(response) + let message = try response.asJSONEncodedString() + dispatcher.send(message) { [weak self] in + if let error = $0 { + self?.logger.debug("Failed to dispatch response: \(response), error: \(error)") } } } diff --git a/Sources/WalletConnectRelay/RelayJSONRPC.swift b/Sources/WalletConnectRelay/RelayJSONRPC.swift deleted file mode 100644 index e83de4427..000000000 --- a/Sources/WalletConnectRelay/RelayJSONRPC.swift +++ /dev/null @@ -1,63 +0,0 @@ -// - -import Foundation - -enum RelayJSONRPC { - enum Method { - case subscribe - case publish - case subscription - case unsubscribe - } - - struct PublishParams: Codable, Equatable { - let topic: String - let message: String - let ttl: Int - let prompt: Bool? - let tag: Int? - } - - struct SubscribeParams: Codable, Equatable { - let topic: String - } - - struct SubscriptionData: Codable, Equatable { - let topic: String - let message: String - } - - struct SubscriptionParams: Codable, Equatable { - let id: String - let data: SubscriptionData - } - - struct UnsubscribeParams: Codable, Equatable { - let id: String - let topic: String - } -} - -extension RelayJSONRPC.Method { - - var prefix: String { - return "iridium" - } - - var name: String { - switch self { - case .subscribe: - return "subscribe" - case .publish: - return "publish" - case .subscription: - return "subscription" - case .unsubscribe: - return "unsubscribe" - } - } - - var method: String { - return "\(prefix)_\(name)" - } -} diff --git a/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift b/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift index 8892ff6a7..2ea62420d 100644 --- a/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift +++ b/Sources/WalletConnectSign/NetworkInteractor/NetworkRelaying.swift @@ -11,9 +11,9 @@ protocol NetworkRelaying { func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) throws func publish(topic: String, payload: String, tag: Int, prompt: Bool) async throws /// - returns: request id - @discardableResult func publish(topic: String, payload: String, tag: Int, prompt: Bool, onNetworkAcknowledge: @escaping ((Error?) -> Void)) -> Int64 + func publish(topic: String, payload: String, tag: Int, prompt: Bool, onNetworkAcknowledge: @escaping ((Error?) -> Void)) func subscribe(topic: String, completion: @escaping (Error?) -> Void) func subscribe(topic: String) async throws /// - returns: request id - @discardableResult func unsubscribe(topic: String, completion: @escaping ((Error?) -> Void)) -> Int64? + func unsubscribe(topic: String, completion: @escaping ((Error?) -> Void)) } diff --git a/Sources/WalletConnectUtils/Encodable.swift b/Sources/WalletConnectUtils/Encodable.swift index 74d14671c..4d18c65b9 100644 --- a/Sources/WalletConnectUtils/Encodable.swift +++ b/Sources/WalletConnectUtils/Encodable.swift @@ -8,6 +8,8 @@ public enum DataConversionError: Error { } public extension Encodable { + + // TODO: Migrate func json() throws -> String { let data = try JSONEncoder().encode(self) guard let string = String(data: data, encoding: .utf8) else { @@ -15,4 +17,12 @@ public extension Encodable { } return string } + + func asJSONEncodedString() throws -> String { + let data = try JSONEncoder().encode(self) + guard let string = String(data: data, encoding: .utf8) else { + throw DataConversionError.dataToStringFailed + } + return string + } } diff --git a/Sources/WalletConnectUtils/RPCHistory.swift b/Sources/WalletConnectUtils/RPCHistory.swift new file mode 100644 index 000000000..86c5b8d05 --- /dev/null +++ b/Sources/WalletConnectUtils/RPCHistory.swift @@ -0,0 +1,67 @@ +import JSONRPC + +public final class RPCHistory { + + public struct Record: Codable { + public enum Origin: String, Codable { + case local + case remote + } + let id: RPCID + let topic: String + let origin: Origin + let request: RPCRequest + var response: RPCResponse? = nil + } + + enum HistoryError: Error { + case unidentifiedRequest + case unidentifiedResponse + case requestDuplicateNotAllowed + case responseDuplicateNotAllowed + case requestMatchingResponseNotFound + } + + private let storage: CodableStore + + public init(keyValueStore: CodableStore) { + self.storage = keyValueStore + } + + public func get(recordId: RPCID) -> Record? { + try? storage.get(key: "\(recordId)") + } + + public func set(_ request: RPCRequest, forTopic topic: String, emmitedBy origin: Record.Origin) throws { + guard let id = request.id else { + throw HistoryError.unidentifiedRequest + } + guard get(recordId: id) == nil else { + throw HistoryError.requestDuplicateNotAllowed + } + let record = Record(id: id, topic: topic, origin: origin, request: request) + storage.set(record, forKey: "\(record.id)") + } + + public func resolve(_ response: RPCResponse) throws { + guard let id = response.id else { + throw HistoryError.unidentifiedResponse + } + guard var record = get(recordId: id) else { + throw HistoryError.requestMatchingResponseNotFound + } + guard record.response == nil else { + throw HistoryError.responseDuplicateNotAllowed + } + record.response = response + storage.set(record, forKey: "\(record.id)") + } + + public func deleteAll(forTopic topic: String) { + storage.getAll().forEach { record in + if record.topic == topic { + storage.delete(forKey: "\(record.id)") + } + } + } +} diff --git a/Tests/RelayerTests/IridiumRelayTests.swift b/Tests/RelayerTests/IridiumRelayTests.swift deleted file mode 100644 index 4ce71e96f..000000000 --- a/Tests/RelayerTests/IridiumRelayTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -import WalletConnectUtils -import Foundation -import Combine -import XCTest -@testable import WalletConnectRelay - -class IridiumRelayTests: XCTestCase { - var iridiumRelay: RelayClient! - var dispatcher: DispatcherMock! - - override func setUp() { - dispatcher = DispatcherMock() - let logger = ConsoleLogger() - iridiumRelay = RelayClient(dispatcher: dispatcher, logger: logger, keyValueStorage: RuntimeKeyValueStorage()) - } - - override func tearDown() { - iridiumRelay = nil - dispatcher = nil - } - - func testNotifyOnSubscriptionRequest() { - let subscriptionExpectation = expectation(description: "notifies with encoded message on a iridium subscription event") - let topic = "0987" - let message = "qwerty" - let subscriptionId = "sub-id" - let subscriptionParams = RelayJSONRPC.SubscriptionParams(id: subscriptionId, data: RelayJSONRPC.SubscriptionData(topic: topic, message: message)) - let subscriptionRequest = JSONRPCRequest(id: 12345, method: RelayJSONRPC.Method.subscription.method, params: subscriptionParams) - iridiumRelay.onMessage = { subscriptionTopic, subscriptionMessage in - XCTAssertEqual(subscriptionMessage, message) - XCTAssertEqual(subscriptionTopic, topic) - subscriptionExpectation.fulfill() - } - dispatcher.onMessage?(try! subscriptionRequest.json()) - waitForExpectations(timeout: 0.001, handler: nil) - } - - func testPublishRequestAcknowledge() { - let acknowledgeExpectation = expectation(description: "completion with no error on iridium request acknowledge after publish") - let requestId = iridiumRelay.publish(topic: "", payload: "{}", tag: 0, onNetworkAcknowledge: { error in - acknowledgeExpectation.fulfill() - XCTAssertNil(error) - }) - let response = try! JSONRPCResponse(id: requestId, result: true).json() - dispatcher.onMessage?(response) - waitForExpectations(timeout: 0.001, handler: nil) - } - - func testUnsubscribeRequestAcknowledge() { - let acknowledgeExpectation = expectation(description: "completion with no error on iridium request acknowledge after unsubscribe") - let topic = "1234" - iridiumRelay.subscriptions[topic] = "" - let requestId = iridiumRelay.unsubscribe(topic: topic) { error in - XCTAssertNil(error) - acknowledgeExpectation.fulfill() - } - let response = try! JSONRPCResponse(id: requestId!, result: true).json() - dispatcher.onMessage?(response) - waitForExpectations(timeout: 0.001, handler: nil) - } - - func testSubscriptionRequestDeliveredOnce() { - let expectation = expectation(description: "Request duplicate not delivered") - let subscriptionParams = RelayJSONRPC.SubscriptionParams(id: "sub_id", data: RelayJSONRPC.SubscriptionData(topic: "topic", message: "message")) - let subscriptionRequest = JSONRPCRequest(id: 12345, method: RelayJSONRPC.Method.subscription.method, params: subscriptionParams) - iridiumRelay.onMessage = { _, _ in - expectation.fulfill() - } - dispatcher.onMessage?(try! subscriptionRequest.json()) - dispatcher.onMessage?(try! subscriptionRequest.json()) - waitForExpectations(timeout: 0.001, handler: nil) - } - - func testSendOnPublish() { - iridiumRelay.publish(topic: "", payload: "", tag: 0, onNetworkAcknowledge: { _ in}) - XCTAssertTrue(dispatcher.sent) - } - - func testSendOnSubscribe() { - iridiumRelay.subscribe(topic: "") {_ in } - XCTAssertTrue(dispatcher.sent) - } - - func testSendOnUnsubscribe() { - let topic = "123" - iridiumRelay.subscriptions[topic] = "" - iridiumRelay.unsubscribe(topic: topic) {_ in } - XCTAssertTrue(dispatcher.sent) - } -} diff --git a/Tests/RelayerTests/Mocks/DispatcherMock.swift b/Tests/RelayerTests/Mocks/DispatcherMock.swift index 4efc9c222..97ddac5dc 100644 --- a/Tests/RelayerTests/Mocks/DispatcherMock.swift +++ b/Tests/RelayerTests/Mocks/DispatcherMock.swift @@ -1,17 +1,32 @@ import Foundation +import JSONRPC @testable import WalletConnectRelay class DispatcherMock: Dispatching { + var onConnect: (() -> Void)? var onDisconnect: (() -> Void)? var onMessage: ((String) -> Void)? + + func connect() {} + func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) {} + var sent = false + var lastMessage: String = "" + func send(_ string: String, completion: @escaping (Error?) -> Void) { sent = true + lastMessage = string } func send(_ string: String) async throws { send(string, completion: { _ in }) } - func connect() {} - func disconnect(closeCode: URLSessionWebSocketTask.CloseCode) {} +} + +extension DispatcherMock { + + func getLastRequestSent() -> RPCRequest { + let data = lastMessage.data(using: .utf8)! + return try! JSONDecoder().decode(RPCRequest.self, from: data) + } } diff --git a/Tests/RelayerTests/RelayClientTests.swift b/Tests/RelayerTests/RelayClientTests.swift new file mode 100644 index 000000000..d45bb61fe --- /dev/null +++ b/Tests/RelayerTests/RelayClientTests.swift @@ -0,0 +1,106 @@ +import WalletConnectUtils +import Foundation +import Combine +import JSONRPC +import XCTest +@testable import WalletConnectRelay + +final class RelayClientTests: XCTestCase { + + var sut: RelayClient! + var dispatcher: DispatcherMock! + + override func setUp() { + dispatcher = DispatcherMock() + let logger = ConsoleLogger() + sut = RelayClient(dispatcher: dispatcher, logger: logger, keyValueStorage: RuntimeKeyValueStorage()) + } + + override func tearDown() { + sut = nil + dispatcher = nil + } + + func testNotifyOnSubscriptionRequest() { + let expectation = expectation(description: "Relay must notify listener on a Subscription request") + let topic = "0987" + let message = "qwerty" + let subscriptionId = "sub-id" + let subscription = Subscription(id: subscriptionId, topic: topic, message: message) + let request = subscription.asRPCRequest() + + sut.onMessage = { subscriptionTopic, subscriptionMessage in + XCTAssertEqual(subscriptionMessage, message) + XCTAssertEqual(subscriptionTopic, topic) + expectation.fulfill() + } + dispatcher.onMessage?(try! request.asJSONEncodedString()) + waitForExpectations(timeout: 0.001, handler: nil) + } + + func testSubscribeRequestAcknowledge() { + let acknowledgeExpectation = expectation(description: "") + sut.subscribe(topic: "") { error in + XCTAssertNil(error) + acknowledgeExpectation.fulfill() + } + let request = dispatcher.getLastRequestSent() + let response = RPCResponse(matchingRequest: request, result: "id") + dispatcher.onMessage?(try! response.asJSONEncodedString()) + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testPublishRequestAcknowledge() { + let expectation = expectation(description: "Publish must callback on relay server acknowledgement") + sut.publish(topic: "", payload: "{}", tag: 0) { error in + XCTAssertNil(error) + expectation.fulfill() + } + let request = dispatcher.getLastRequestSent() + let response = RPCResponse(matchingRequest: request, result: true) + dispatcher.onMessage?(try! response.asJSONEncodedString()) + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testUnsubscribeRequestAcknowledge() { + let expectation = expectation(description: "Unsubscribe must callback on relay server acknowledgement") + let topic = String.randomTopic() + sut.subscriptions[topic] = "" + sut.unsubscribe(topic: topic) { error in + XCTAssertNil(error) + expectation.fulfill() + } + let request = dispatcher.getLastRequestSent() + let response = RPCResponse(matchingRequest: request, result: true) + dispatcher.onMessage?(try! response.asJSONEncodedString()) + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testSubscriptionRequestDeliveredOnce() { + let expectation = expectation(description: "Duplicate Subscription requests must notify only the first time") + let request = Subscription.init(id: "sub_id", topic: "topic", message: "message").asRPCRequest() + sut.onMessage = { _, _ in + expectation.fulfill() + } + dispatcher.onMessage?(try! request.asJSONEncodedString()) + dispatcher.onMessage?(try! request.asJSONEncodedString()) + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testSendOnPublish() { + sut.publish(topic: "", payload: "", tag: 0, onNetworkAcknowledge: { _ in}) + XCTAssertTrue(dispatcher.sent) + } + + func testSendOnSubscribe() { + sut.subscribe(topic: "") {_ in } + XCTAssertTrue(dispatcher.sent) + } + + func testSendOnUnsubscribe() { + let topic = "123" + sut.subscriptions[topic] = "" + sut.unsubscribe(topic: topic) {_ in } + XCTAssertTrue(dispatcher.sent) + } +} diff --git a/Tests/TestingUtils/Mocks/RPC.swift b/Tests/TestingUtils/Mocks/RPC.swift new file mode 100644 index 000000000..964c472b1 --- /dev/null +++ b/Tests/TestingUtils/Mocks/RPC.swift @@ -0,0 +1,12 @@ +import JSONRPC + +public extension RPCRequest { + + static func stub() -> RPCRequest { + RPCRequest(method: "method", params: EmptyCodable()) + } + + static func stub(method: String, id: Int) -> RPCRequest { + RPCRequest(method: method, params: EmptyCodable(), id: id) + } +} diff --git a/Tests/WalletConnectKMSTests/KeychainStorageTests.swift b/Tests/WalletConnectKMSTests/KeychainStorageTests.swift index 19c57bdaf..422f23bcf 100644 --- a/Tests/WalletConnectKMSTests/KeychainStorageTests.swift +++ b/Tests/WalletConnectKMSTests/KeychainStorageTests.swift @@ -32,13 +32,12 @@ final class KeychainStorageTests: XCTestCase { XCTAssertNoThrow(try sut.add(privateKey, forKey: "id-2")) } - func testAddDuplicateItemError() { + func testAddDuplicateItem() throws { let privateKey = Curve25519.KeyAgreement.PrivateKey() - try? sut.add(privateKey, forKey: defaultIdentifier) - XCTAssertThrowsError(try sut.add(privateKey, forKey: defaultIdentifier)) { error in - guard let error = error as? KeychainError else { XCTFail(); return } - XCTAssertEqual(error.status, errSecDuplicateItem) - } + try sut.add(privateKey, forKey: defaultIdentifier) + let newPrivateKey = Curve25519.KeyAgreement.PrivateKey() + XCTAssertNoThrow(try sut.add(newPrivateKey, forKey: defaultIdentifier)) + XCTAssertEqual(try sut.read(key: defaultIdentifier), newPrivateKey) } func testAddUnknownFailure() { diff --git a/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift b/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift index b8de74a1a..98f57f0ef 100644 --- a/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift +++ b/Tests/WalletConnectSignTests/Mocks/MockedRelayClient.swift @@ -18,18 +18,17 @@ class MockedRelayClient: NetworkRelaying { var onMessage: ((String, String) -> Void)? var error: Error? var prompt = false - func publish(topic: String, payload: String, tag: Int, prompt: Bool, onNetworkAcknowledge: @escaping ((Error?) -> Void)) -> Int64 { + func publish(topic: String, payload: String, tag: Int, prompt: Bool, onNetworkAcknowledge: @escaping ((Error?) -> Void)) { self.prompt = prompt onNetworkAcknowledge(error) - return 0 } func subscribe(topic: String, completion: @escaping (Error?) -> Void) { } - func unsubscribe(topic: String, completion: @escaping ((Error?) -> Void)) -> Int64? { - return 0 + func unsubscribe(topic: String, completion: @escaping ((Error?) -> Void)) { } + func connect() { } diff --git a/Tests/WalletConnectUtilsTests/RPCHistoryTests.swift b/Tests/WalletConnectUtilsTests/RPCHistoryTests.swift new file mode 100644 index 000000000..10bfbc049 --- /dev/null +++ b/Tests/WalletConnectUtilsTests/RPCHistoryTests.swift @@ -0,0 +1,110 @@ +import XCTest +import JSONRPC +import TestingUtils +@testable import WalletConnectUtils + +final class RPCHistoryTests: XCTestCase { + + var sut: RPCHistory! + + override func setUp() { + let storage = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "") + sut = RPCHistory(keyValueStore: storage) + } + + override func tearDown() { + sut = nil + } + + // MARK: History Storage Tests + + func testRoundTrip() throws { + let request = RPCRequest.stub() + try sut.set(request, forTopic: String.randomTopic(), emmitedBy: .local) + let record = sut.get(recordId: request.id!) + XCTAssertNil(record?.response) + XCTAssertEqual(record?.request, request) + } + + func testResolveSuccessAndError() throws { + let requestA = RPCRequest.stub() + let requestB = RPCRequest.stub() + let responseA = RPCResponse(matchingRequest: requestA, result: true) + let responseB = RPCResponse(matchingRequest: requestB, error: .internalError) + try sut.set(requestA, forTopic: String.randomTopic(), emmitedBy: .remote) + try sut.set(requestB, forTopic: String.randomTopic(), emmitedBy: .local) + try sut.resolve(responseA) + try sut.resolve(responseB) + let recordA = sut.get(recordId: requestA.id!) + let recordB = sut.get(recordId: requestB.id!) + XCTAssertEqual(recordA?.response, responseA) + XCTAssertEqual(recordB?.response, responseB) + } + + func testDelete() throws { + let requests = (1...5).map { _ in RPCRequest.stub() } + let topic = String.randomTopic() + try requests.forEach { try sut.set($0, forTopic: topic, emmitedBy: .local) } + sut.deleteAll(forTopic: topic) + requests.forEach { + XCTAssertNil(sut.get(recordId: $0.id!)) + } + } + + // MARK: Error Cases Tests + + func testSetUnidentifiedRequest() { + let expectedError = RPCHistory.HistoryError.unidentifiedRequest + + let request = RPCRequest.notification(method: "notify") + XCTAssertThrowsError(try sut.set(request, forTopic: String.randomTopic(), emmitedBy: .local)) { error in + XCTAssertEqual(expectedError, error as? RPCHistory.HistoryError) + } + } + + func testSetDuplicateRequest() throws { + let expectedError = RPCHistory.HistoryError.requestDuplicateNotAllowed + + let id = Int.random() + let requestA = RPCRequest.stub(method: "method-1", id: id) + let requestB = RPCRequest.stub(method: "method-2", id: id) + let topic = String.randomTopic() + + try sut.set(requestA, forTopic: topic, emmitedBy: .local) + XCTAssertThrowsError(try sut.set(requestB, forTopic: topic, emmitedBy: .local)) { error in + XCTAssertEqual(expectedError, error as? RPCHistory.HistoryError) + } + } + + func testResolveResponseWithoutRequest() throws { + let expectedError = RPCHistory.HistoryError.requestMatchingResponseNotFound + + let response = RPCResponse(id: 0, result: true) + XCTAssertThrowsError(try sut.resolve(response)) { error in + XCTAssertEqual(expectedError, error as? RPCHistory.HistoryError) + } + } + + func testResolveUnidentifiedResponse() throws { + let expectedError = RPCHistory.HistoryError.unidentifiedResponse + + let response = RPCResponse(errorWithoutID: JSONRPCError.internalError) + XCTAssertThrowsError(try sut.resolve(response)) { error in + XCTAssertEqual(expectedError, error as? RPCHistory.HistoryError) + } + } + + func testResolveDuplicateResponse() throws { + let expectedError = RPCHistory.HistoryError.responseDuplicateNotAllowed + + let request = RPCRequest.stub() + let responseA = RPCResponse(matchingRequest: request, result: true) + let responseB = RPCResponse(matchingRequest: request, result: false) + + try sut.set(request, forTopic: String.randomTopic(), emmitedBy: .local) + try sut.resolve(responseA) + XCTAssertThrowsError(try sut.resolve(responseB)) { error in + XCTAssertEqual(expectedError, error as? RPCHistory.HistoryError) + } + } +}