diff --git a/Example/ExampleApp/Wallet/WalletViewController.swift b/Example/ExampleApp/Wallet/WalletViewController.swift index 59fe0fe04..f5ede5ab5 100644 --- a/Example/ExampleApp/Wallet/WalletViewController.swift +++ b/Example/ExampleApp/Wallet/WalletViewController.swift @@ -168,7 +168,11 @@ extension WalletViewController: ProposalViewControllerDelegate { print("did reject session") let proposal = currentProposal! currentProposal = nil - Sign.instance.reject(proposal: proposal, reason: .disapprovedChains) + do { + try Sign.instance.reject(proposalId: proposal.id, reason: .disapprovedChains) + } catch { + print("Session rejection error: \(error.localizedDescription)") + } } } diff --git a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift new file mode 100644 index 000000000..dceb8f3ac --- /dev/null +++ b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift @@ -0,0 +1,304 @@ +import Foundation +import Combine +import WalletConnectUtils +import WalletConnectKMS + +final class ApproveEngine { + + enum Error: String, Swift.Error { + case wrongRequestParams + case relayNotFound + case proposalPayloadsNotFound + case pairingNotFound + case agreementMissingOrInvalid + } + + var onSessionProposal: ((Session.Proposal) -> Void)? + var onSessionRejected: ((Session.Proposal, SessionType.Reason) -> Void)? + var onSessionSettle: ((Session) -> Void)? + + var settlingProposal: SessionProposal? + + private let networkingInteractor: NetworkInteracting + private let pairingStore: WCPairingStorage + private let sessionStore: WCSessionStorage + private let proposalPayloadsStore: CodableStore + private let sessionToPairingTopic: CodableStore + private let metadata: AppMetadata + private let kms: KeyManagementServiceProtocol + private let logger: ConsoleLogging + + private var publishers = Set() + + init( + networkingInteractor: NetworkInteracting, + proposalPayloadsStore: CodableStore, + sessionToPairingTopic: CodableStore, + metadata: AppMetadata, + kms: KeyManagementServiceProtocol, + logger: ConsoleLogging, + pairingStore: WCPairingStorage, + sessionStore: WCSessionStorage + ) { + self.networkingInteractor = networkingInteractor + self.proposalPayloadsStore = proposalPayloadsStore + self.sessionToPairingTopic = sessionToPairingTopic + self.metadata = metadata + self.kms = kms + self.logger = logger + self.pairingStore = pairingStore + self.sessionStore = sessionStore + + setupNetworkingSubscriptions() + } + + func approveProposal(proposerPubKey: String, validating sessionNamespaces: [String: SessionNamespace]) throws { + let payload = try proposalPayloadsStore.get(key: proposerPubKey) + + guard let payload = payload, case .sessionPropose(let proposal) = payload.wcRequest.params else { + throw Error.wrongRequestParams + } + + proposalPayloadsStore.delete(forKey: proposerPubKey) + + try Namespace.validate(sessionNamespaces) + try Namespace.validateApproved(sessionNamespaces, against: proposal.requiredNamespaces) + + let selfPublicKey = try kms.createX25519KeyPair() + + guard let agreementKey = try? kms.performKeyAgreement( + selfPublicKey: selfPublicKey, + peerPublicKey: proposal.proposer.publicKey + ) else { throw Error.agreementMissingOrInvalid } + + // TODO: Extend pairing + let sessionTopic = agreementKey.derivedTopic() + try kms.setAgreementSecret(agreementKey, topic: sessionTopic) + + guard let relay = proposal.relays.first else { + throw Error.relayNotFound + } + + let proposeResponse = SessionType.ProposeResponse(relay: relay, responderPublicKey: selfPublicKey.hexRepresentation) + let response = JSONRPCResponse(id: payload.wcRequest.id, result: AnyCodable(proposeResponse)) + networkingInteractor.respond(topic: payload.topic, response: .response(response)) { _ in } + + try settle(topic: sessionTopic, proposal: proposal, namespaces: sessionNamespaces) + } + + func reject(proposerPubKey: String, reason: ReasonCode) throws { + guard let payload = try proposalPayloadsStore.get(key: proposerPubKey) else { + throw Error.proposalPayloadsNotFound + } + proposalPayloadsStore.delete(forKey: proposerPubKey) + networkingInteractor.respondError(for: payload, reason: reason) + // TODO: Delete pairing if inactive + } + + func settle(topic: String, proposal: SessionProposal, namespaces: [String: SessionNamespace]) throws { + guard let agreementKeys = try kms.getAgreementSecret(for: topic) else { + throw Error.agreementMissingOrInvalid + } + let selfParticipant = Participant( + publicKey: agreementKeys.publicKey.hexRepresentation, + metadata: metadata + ) + let expectedExpiryTimeStamp = Date().addingTimeInterval(TimeInterval(WCSession.defaultTimeToLive)) + + guard let relay = proposal.relays.first else { + throw Error.relayNotFound + } + + // TODO: Test expiration times + let settleParams = SessionType.SettleParams( + relay: relay, + controller: selfParticipant, + namespaces: namespaces, + expiry: Int64(expectedExpiryTimeStamp.timeIntervalSince1970) + ) + let session = WCSession( + topic: topic, + selfParticipant: selfParticipant, + peerParticipant: proposal.proposer, + settleParams: settleParams, + acknowledged: false + ) + + logger.debug("Sending session settle request") + + Task { try? await networkingInteractor.subscribe(topic: topic) } + sessionStore.setSession(session) + networkingInteractor.request(.wcSessionSettle(settleParams), onTopic: topic) + onSessionSettle?(session.publicRepresentation()) + } +} + +// MARK: - Privates + +private extension ApproveEngine { + + func setupNetworkingSubscriptions() { + networkingInteractor.responsePublisher + .sink { [unowned self] response in + switch response.requestParams { + case .sessionPropose(let proposal): + handleSessionProposeResponse(response: response, proposal: proposal) + case .sessionSettle: + handleSessionSettleResponse(response: response) + default: + break + } + }.store(in: &publishers) + + networkingInteractor.wcRequestPublisher + .sink { [unowned self] subscriptionPayload in + switch subscriptionPayload.wcRequest.params { + case .sessionPropose(let proposal): + handleSessionProposeRequest(payload: subscriptionPayload, proposal: proposal) + case .sessionSettle(let settleParams): + handleSessionSettleRequest(payload: subscriptionPayload, settleParams: settleParams) + default: + return + } + }.store(in: &publishers) + } + + func updatePairingMetadata(topic: String, metadata: AppMetadata) { + guard var pairing = pairingStore.getPairing(forTopic: topic) else { return } + pairing.peerMetadata = metadata + pairingStore.setPairing(pairing) + } + + // MARK: SessionProposeResponse + // TODO: Move to Non-Controller SettleEngine + func handleSessionProposeResponse(response: WCResponse, proposal: SessionType.ProposeParams) { + do { + let sessionTopic = try handleProposeResponse( + pairingTopic: response.topic, + proposal: proposal, + result: response.result + ) + settlingProposal = proposal + + Task { try? await networkingInteractor.subscribe(topic: sessionTopic) } + } + catch { + guard let error = error as? JSONRPCErrorResponse else { + return logger.debug(error.localizedDescription) + } + onSessionRejected?(proposal.publicRepresentation(), SessionType.Reason(code: error.error.code, message: error.error.message)) + } + } + + func handleProposeResponse(pairingTopic: String, proposal: SessionProposal, result: JsonRpcResult) throws -> String { + guard var pairing = pairingStore.getPairing(forTopic: pairingTopic) + else { throw Error.pairingNotFound } + + switch result { + case .response(let response): + // Activate the pairing + if !pairing.active { + pairing.activate() + } else { + try pairing.updateExpiry() + } + + pairingStore.setPairing(pairing) + + let selfPublicKey = try AgreementPublicKey(hex: proposal.proposer.publicKey) + let proposeResponse = try response.result.get(SessionType.ProposeResponse.self) + let agreementKeys = try kms.performKeyAgreement(selfPublicKey: selfPublicKey, peerPublicKey: proposeResponse.responderPublicKey) + + let sessionTopic = agreementKeys.derivedTopic() + logger.debug("Received Session Proposal response") + + try kms.setAgreementSecret(agreementKeys, topic: sessionTopic) + sessionToPairingTopic.set(pairingTopic, forKey: sessionTopic) + + return sessionTopic + + case .error(let error): + if !pairing.active { + kms.deleteSymmetricKey(for: pairing.topic) + networkingInteractor.unsubscribe(topic: pairing.topic) + pairingStore.delete(topic: pairingTopic) + } + logger.debug("Session Proposal has been rejected") + kms.deletePrivateKey(for: proposal.proposer.publicKey) + throw error + } + } + + // MARK: SessionSettleResponse + + func handleSessionSettleResponse(response: WCResponse) { + guard let session = sessionStore.getSession(forTopic: response.topic) else { return } + switch response.result { + case .response: + logger.debug("Received session settle response") + guard var session = sessionStore.getSession(forTopic: response.topic) else { return } + session.acknowledge() + sessionStore.setSession(session) + case .error(let error): + logger.error("Error - session rejected, Reason: \(error)") + networkingInteractor.unsubscribe(topic: response.topic) + sessionStore.delete(topic: response.topic) + kms.deleteAgreementSecret(for: response.topic) + kms.deletePrivateKey(for: session.publicKey!) + } + } + + // MARK: SessionProposeRequest + + func handleSessionProposeRequest(payload: WCRequestSubscriptionPayload, proposal: SessionType.ProposeParams) { + do { + logger.debug("Received Session Proposal") + try Namespace.validate(proposal.requiredNamespaces) + proposalPayloadsStore.set(payload, forKey: proposal.proposer.publicKey) + onSessionProposal?(proposal.publicRepresentation()) + } + catch { + // TODO: Return reasons with 6000 code Issue: #253 + networkingInteractor.respondError(for: payload, reason: .invalidUpdateNamespaceRequest) + } + } + + // MARK: SessionSettleRequest + func handleSessionSettleRequest(payload: WCRequestSubscriptionPayload, settleParams: SessionType.SettleParams) { + logger.debug("Did receive session settle request") + guard let proposedNamespaces = settlingProposal?.requiredNamespaces else { + // TODO: respond error + return + } + settlingProposal = nil + let sessionNamespaces = settleParams.namespaces + do { + try Namespace.validate(proposedNamespaces) + try Namespace.validate(sessionNamespaces) + try Namespace.validateApproved(sessionNamespaces, against: proposedNamespaces) + } catch { + // TODO: respond error + return + } + + let topic = payload.topic + + let agreementKeys = try! kms.getAgreementSecret(for: topic)! + + let selfParticipant = Participant(publicKey: agreementKeys.publicKey.hexRepresentation, metadata: metadata) + + if let pairingTopic = try? sessionToPairingTopic.get(key: topic) { + updatePairingMetadata(topic: pairingTopic, metadata: settleParams.controller.metadata) + } + + let session = WCSession( + topic: topic, + selfParticipant: selfParticipant, + peerParticipant: settleParams.controller, + settleParams: settleParams, + acknowledged: true) + sessionStore.setSession(session) + networkingInteractor.respondSuccess(for: payload) + onSessionSettle?(session.publicRepresentation()) + } +} diff --git a/Sources/WalletConnectSign/Engine/Common/PairingEngine.swift b/Sources/WalletConnectSign/Engine/Common/PairingEngine.swift index 4d242e7e8..1ea9359dd 100644 --- a/Sources/WalletConnectSign/Engine/Common/PairingEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/PairingEngine.swift @@ -3,44 +3,32 @@ import Combine import WalletConnectUtils import WalletConnectKMS - final class PairingEngine { - var onSessionProposal: ((Session.Proposal)->())? - var onProposeResponse: ((String, SessionProposal)->())? - var onSessionRejected: ((Session.Proposal, SessionType.Reason)->())? - - private let proposalPayloadsStore: CodableStore + private let networkingInteractor: NetworkInteracting private let kms: KeyManagementServiceProtocol private let pairingStore: WCPairingStorage - private let sessionToPairingTopic: CodableStore private var metadata: AppMetadata private var publishers = [AnyCancellable]() private let logger: ConsoleLogging private let topicInitializer: () -> String - - init(networkingInteractor: NetworkInteracting, - kms: KeyManagementServiceProtocol, - pairingStore: WCPairingStorage, - sessionToPairingTopic: CodableStore, - metadata: AppMetadata, - logger: ConsoleLogging, - topicGenerator: @escaping () -> String = String.generateTopic, - proposalPayloadsStore: CodableStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: StorageDomainIdentifiers.proposals.rawValue)) { + + init( + networkingInteractor: NetworkInteracting, + kms: KeyManagementServiceProtocol, + pairingStore: WCPairingStorage, + metadata: AppMetadata, + logger: ConsoleLogging, + topicGenerator: @escaping () -> String = String.generateTopic + ) { self.networkingInteractor = networkingInteractor self.kms = kms self.metadata = metadata self.pairingStore = pairingStore self.logger = logger self.topicInitializer = topicGenerator - self.sessionToPairingTopic = sessionToPairingTopic - self.proposalPayloadsStore = proposalPayloadsStore - setUpWCRequestHandling() + setupNetworkingSubscriptions() setupExpirationHandling() - restoreSubscriptions() - networkingInteractor.onPairingResponse = { [weak self] in - self?.handleResponse($0) - } } func hasPairing(for topic: String) -> Bool { @@ -106,147 +94,39 @@ final class PairingEngine { } } } - - func reject(proposal: SessionProposal, reason: ReasonCode) { - guard let payload = try? proposalPayloadsStore.get(key: proposal.proposer.publicKey) else { - return - } - proposalPayloadsStore.delete(forKey: proposal.proposer.publicKey) - networkingInteractor.respondError(for: payload, reason: reason) -// todo - delete pairing if inactive - } - - func approveProposal(proposerPubKey: String, validating sessionNamespaces: [String: SessionNamespace]) -> (String, SessionProposal)? { - guard let payload = try? proposalPayloadsStore.get(key: proposerPubKey), - case .sessionPropose(let proposal) = payload.wcRequest.params else { - //TODO - throws - return nil - } - let proposedNamespaces = proposal.requiredNamespaces - proposalPayloadsStore.delete(forKey: proposerPubKey) - - do { - try Namespace.validate(sessionNamespaces) - try Namespace.validateApproved(sessionNamespaces, against: proposedNamespaces) - let selfPublicKey = try! kms.createX25519KeyPair() - let agreementKey = try kms.performKeyAgreement(selfPublicKey: selfPublicKey, peerPublicKey: proposal.proposer.publicKey) - //todo - extend pairing - let sessionTopic = agreementKey.derivedTopic() - - try! kms.setAgreementSecret(agreementKey, topic: sessionTopic) - guard let relay = proposal.relays.first else {return nil} - let proposeResponse = SessionType.ProposeResponse(relay: relay, responderPublicKey: selfPublicKey.hexRepresentation) - let response = JSONRPCResponse(id: payload.wcRequest.id, result: AnyCodable(proposeResponse)) - logger.debug("Responding session propose") - networkingInteractor.respond(topic: payload.topic, response: .response(response)) { _ in } - return (sessionTopic, proposal) - } catch { - networkingInteractor.respondError(for: payload, reason: .missingOrInvalid("agreement keys")) - return nil - } - } +} - //MARK: - Private +// MARK: Private - private func setUpWCRequestHandling() { - networkingInteractor.wcRequestPublisher.sink { [unowned self] subscriptionPayload in - switch subscriptionPayload.wcRequest.params { - case .pairingPing(_): - wcPairingPing(subscriptionPayload) - case .sessionPropose(let proposeParams): - wcSessionPropose(subscriptionPayload, proposal: proposeParams) - default: - return - } - }.store(in: &publishers) - } - - private func wcSessionPropose(_ payload: WCRequestSubscriptionPayload, proposal: SessionType.ProposeParams) { - logger.debug("Received Session Proposal") - do { - try Namespace.validate(proposal.requiredNamespaces) - } catch { - // TODO: respond error - return - } - try? proposalPayloadsStore.set(payload, forKey: proposal.proposer.publicKey) - onSessionProposal?(proposal.publicRepresentation()) - } - - private func wcPairingPing(_ payload: WCRequestSubscriptionPayload) { - networkingInteractor.respondSuccess(for: payload) - } +private extension PairingEngine { - private func restoreSubscriptions() { + func setupNetworkingSubscriptions() { networkingInteractor.transportConnectionPublisher .sink { [unowned self] (_) in let topics = pairingStore.getAll() .map{$0.topic} topics.forEach{ topic in Task{try? await networkingInteractor.subscribe(topic: topic)}} }.store(in: &publishers) + + networkingInteractor.wcRequestPublisher + .sink { [unowned self] subscriptionPayload in + switch subscriptionPayload.wcRequest.params { + case .pairingPing(_): + wcPairingPing(subscriptionPayload) + default: + return + } + }.store(in: &publishers) + } + + func wcPairingPing(_ payload: WCRequestSubscriptionPayload) { + networkingInteractor.respondSuccess(for: payload) } - private func setupExpirationHandling() { + func setupExpirationHandling() { pairingStore.onPairingExpiration = { [weak self] pairing in self?.kms.deleteSymmetricKey(for: pairing.topic) self?.networkingInteractor.unsubscribe(topic: pairing.topic) } } - - private func handleResponse(_ response: WCResponse) { - switch response.requestParams { - case .sessionPropose(let proposal): - handleProposeResponse(pairingTopic: response.topic, proposal: proposal, result: response.result) - default: - break - } - } - - private func handleProposeResponse(pairingTopic: String, proposal: SessionProposal, result: JsonRpcResult) { - guard var pairing = pairingStore.getPairing(forTopic: pairingTopic) else { - return - } - switch result { - case .response(let response): - - // Activate the pairing - if !pairing.active { - pairing.activate() - } else { - try? pairing.updateExpiry() - } - - pairingStore.setPairing(pairing) - - let selfPublicKey = try! AgreementPublicKey(hex: proposal.proposer.publicKey) - var agreementKeys: AgreementKeys! - - do { - let proposeResponse = try response.result.get(SessionType.ProposeResponse.self) - agreementKeys = try kms.performKeyAgreement(selfPublicKey: selfPublicKey, peerPublicKey: proposeResponse.responderPublicKey) - } catch { - //TODO - handle error - logger.debug(error) - return - } - - let sessionTopic = agreementKeys.derivedTopic() - logger.debug("Received Session Proposal response") - - try? kms.setAgreementSecret(agreementKeys, topic: sessionTopic) - try! sessionToPairingTopic.set(pairingTopic, forKey: sessionTopic) - onProposeResponse?(sessionTopic, proposal) - - case .error(let error): - if !pairing.active { - kms.deleteSymmetricKey(for: pairing.topic) - networkingInteractor.unsubscribe(topic: pairing.topic) - pairingStore.delete(topic: pairingTopic) - } - logger.debug("Session Proposal has been rejected") - kms.deletePrivateKey(for: proposal.proposer.publicKey) - onSessionRejected?(proposal.publicRepresentation(), SessionType.Reason(code: error.error.code, message: error.error.message)) - return - } - } } diff --git a/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift b/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift index 56f930780..e61541731 100644 --- a/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift @@ -3,54 +3,33 @@ import Combine import WalletConnectUtils import WalletConnectKMS - final class SessionEngine { - var onSessionRequest: ((Request)->())? - var onSessionResponse: ((Response)->())? - var onSessionSettle: ((Session)->())? - var onSessionRejected: ((String, SessionType.Reason)->())? - var onSessionDelete: ((String, SessionType.Reason)->())? - var onEventReceived: ((String, Session.Event, Blockchain?)->())? - - var settlingProposal: SessionProposal? - + + var onSessionRequest: ((Request) -> Void)? + var onSessionResponse: ((Response) -> Void)? + var onSessionRejected: ((String, SessionType.Reason) -> Void)? + var onSessionDelete: ((String, SessionType.Reason) -> Void)? + var onEventReceived: ((String, Session.Event, Blockchain?) -> Void)? + private let sessionStore: WCSessionStorage - private let pairingStore: WCPairingStorage - private let sessionToPairingTopic: CodableStore private let networkingInteractor: NetworkInteracting private let kms: KeyManagementServiceProtocol - private var metadata: AppMetadata private var publishers = [AnyCancellable]() private let logger: ConsoleLogging - private let topicInitializer: () -> String - init(networkingInteractor: NetworkInteracting, - kms: KeyManagementServiceProtocol, - pairingStore: WCPairingStorage, - sessionStore: WCSessionStorage, - sessionToPairingTopic: CodableStore, - metadata: AppMetadata, - logger: ConsoleLogging, - topicGenerator: @escaping () -> String = String.generateTopic) { + init( + networkingInteractor: NetworkInteracting, + kms: KeyManagementServiceProtocol, + sessionStore: WCSessionStorage, + logger: ConsoleLogging + ) { self.networkingInteractor = networkingInteractor self.kms = kms - self.metadata = metadata self.sessionStore = sessionStore - self.pairingStore = pairingStore - self.sessionToPairingTopic = sessionToPairingTopic self.logger = logger - self.topicInitializer = topicGenerator - setUpWCRequestHandling() - setupExpirationHandling() - restoreSubscriptions() - networkingInteractor.onResponse = { [weak self] in - self?.handleResponse($0) - } - } - - func setSubscription(topic: String) { - Task { try? await networkingInteractor.subscribe(topic: topic) } + setupNetworkingSubscriptions() + setupExpirationSubscriptions() } func hasSession(for topic: String) -> Bool { @@ -61,30 +40,6 @@ final class SessionEngine { sessionStore.getAll().map{$0.publicRepresentation()} } - func settle(topic: String, proposal: SessionProposal, namespaces: [String: SessionNamespace]) throws { - let agreementKeys = try! kms.getAgreementSecret(for: topic)! - let selfParticipant = Participant(publicKey: agreementKeys.publicKey.hexRepresentation, metadata: metadata) - - let expectedExpiryTimeStamp = Date().addingTimeInterval(TimeInterval(WCSession.defaultTimeToLive)) - guard let relay = proposal.relays.first else {return} - let settleParams = SessionType.SettleParams( - relay: relay, - controller: selfParticipant, - namespaces: namespaces, - expiry: Int64(expectedExpiryTimeStamp.timeIntervalSince1970))//todo - test expiration times - let session = WCSession( - topic: topic, - selfParticipant: selfParticipant, - peerParticipant: proposal.proposer, - settleParams: settleParams, - acknowledged: false) - logger.debug("Sending session settle request") - Task { try? await networkingInteractor.subscribe(topic: topic) } - sessionStore.setSession(session) - networkingInteractor.request(.wcSessionSettle(settleParams), onTopic: topic) - onSessionSettle?(session.publicRepresentation()) - } - func delete(topic: String, reason: Reason) async throws { logger.debug("Will delete session for reason: message: \(reason.message) code: \(reason.code)") try await networkingInteractor.request(.wcSessionDelete(reason.internalRepresentation()), onTopic: topic) @@ -147,14 +102,15 @@ final class SessionEngine { let params = SessionType.EventParams(event: event, chainId: chainId) try await networkingInteractor.request(.wcSessionEvent(params), onTopic: topic) } +} + +//MARK: - Privates - //MARK: - Private +private extension SessionEngine { - private func setUpWCRequestHandling() { + func setupNetworkingSubscriptions() { networkingInteractor.wcRequestPublisher.sink { [unowned self] subscriptionPayload in switch subscriptionPayload.wcRequest.params { - case .sessionSettle(let settleParams): - onSessionSettle(payload: subscriptionPayload, settleParams: settleParams) case .sessionDelete(let deleteParams): onSessionDelete(subscriptionPayload, deleteParams: deleteParams) case .sessionRequest(let sessionRequestParams): @@ -167,47 +123,20 @@ final class SessionEngine { return } }.store(in: &publishers) - } - - private func onSessionSettle(payload: WCRequestSubscriptionPayload, settleParams: SessionType.SettleParams) { - logger.debug("Did receive session settle request") - guard let proposedNamespaces = settlingProposal?.requiredNamespaces else { - // TODO: respond error - return - } - settlingProposal = nil - let sessionNamespaces = settleParams.namespaces - do { - try Namespace.validate(proposedNamespaces) - try Namespace.validate(sessionNamespaces) - try Namespace.validateApproved(sessionNamespaces, against: proposedNamespaces) - } catch { - // TODO: respond error - return - } - - let topic = payload.topic - - let agreementKeys = try! kms.getAgreementSecret(for: topic)! - - let selfParticipant = Participant(publicKey: agreementKeys.publicKey.hexRepresentation, metadata: metadata) - if let pairingTopic = try? sessionToPairingTopic.get(key: topic) { - updatePairingMetadata(topic: pairingTopic, metadata: settleParams.controller.metadata) - } + networkingInteractor.transportConnectionPublisher + .sink { [unowned self] (_) in + let topics = sessionStore.getAll().map{$0.topic} + topics.forEach{ topic in Task { try? await networkingInteractor.subscribe(topic: topic) } } + }.store(in: &publishers) - let session = WCSession( - topic: topic, - selfParticipant: selfParticipant, - peerParticipant: settleParams.controller, - settleParams: settleParams, - acknowledged: true) - sessionStore.setSession(session) - networkingInteractor.respondSuccess(for: payload) - onSessionSettle?(session.publicRepresentation()) + networkingInteractor.responsePublisher + .sink { [unowned self] response in + self.handleResponse(response) + }.store(in: &publishers) } - private func onSessionDelete(_ payload: WCRequestSubscriptionPayload, deleteParams: SessionType.DeleteParams) { + func onSessionDelete(_ payload: WCRequestSubscriptionPayload, deleteParams: SessionType.DeleteParams) { let topic = payload.topic guard sessionStore.hasSession(forTopic: topic) else { networkingInteractor.respondError(for: payload, reason: .noContextWithTopic(context: .session, topic: topic)) @@ -219,7 +148,7 @@ final class SessionEngine { onSessionDelete?(topic, deleteParams) } - private func onSessionRequest(_ payload: WCRequestSubscriptionPayload, payloadParams: SessionType.RequestParams) { + func onSessionRequest(_ payload: WCRequestSubscriptionPayload, payloadParams: SessionType.RequestParams) { let topic = payload.topic let jsonRpcRequest = JSONRPCRequest(id: payload.wcRequest.id, method: payloadParams.request.method, params: payloadParams.request.params) let request = Request( @@ -245,11 +174,11 @@ final class SessionEngine { onSessionRequest?(request) } - private func onSessionPing(_ payload: WCRequestSubscriptionPayload) { + func onSessionPing(_ payload: WCRequestSubscriptionPayload) { networkingInteractor.respondSuccess(for: payload) } - private func onSessionEvent(_ payload: WCRequestSubscriptionPayload, eventParams: SessionType.EventParams) { + func onSessionEvent(_ payload: WCRequestSubscriptionPayload, eventParams: SessionType.EventParams) { let event = eventParams.event let topic = payload.topic guard let session = sessionStore.getSession(forTopic: topic) else { @@ -267,25 +196,15 @@ final class SessionEngine { onEventReceived?(topic, event.publicRepresentation(), eventParams.chainId) } - private func setupExpirationHandling() { + func setupExpirationSubscriptions() { sessionStore.onSessionExpiration = { [weak self] session in self?.kms.deletePrivateKey(for: session.selfParticipant.publicKey) self?.kms.deleteAgreementSecret(for: session.topic) } } - - private func restoreSubscriptions() { - networkingInteractor.transportConnectionPublisher - .sink { [unowned self] (_) in - let topics = sessionStore.getAll().map{$0.topic} - topics.forEach{ topic in Task { try? await networkingInteractor.subscribe(topic: topic) } } - }.store(in: &publishers) - } - - private func handleResponse(_ response: WCResponse) { + + func handleResponse(_ response: WCResponse) { switch response.requestParams { - case .sessionSettle: - handleSessionSettleResponse(topic: response.topic, result: response.result) case .sessionRequest: let response = Response(topic: response.topic, chainId: response.chainId, result: response.result) onSessionResponse?(response) @@ -293,27 +212,4 @@ final class SessionEngine { break } } - - func handleSessionSettleResponse(topic: String, result: JsonRpcResult) { - guard let session = sessionStore.getSession(forTopic: topic) else {return} - switch result { - case .response: - logger.debug("Received session settle response") - guard var session = sessionStore.getSession(forTopic: topic) else {return} - session.acknowledge() - sessionStore.setSession(session) - case .error(let error): - logger.error("Error - session rejected, Reason: \(error)") - networkingInteractor.unsubscribe(topic: topic) - sessionStore.delete(topic: topic) - kms.deleteAgreementSecret(for: topic) - kms.deletePrivateKey(for: session.publicKey!) - } - } - - private func updatePairingMetadata(topic: String, metadata: AppMetadata) { - guard var pairing = pairingStore.getPairing(forTopic: topic) else {return} - pairing.peerMetadata = metadata - pairingStore.setPairing(pairing) - } } diff --git a/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift b/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift index 4855d460d..c113fccce 100644 --- a/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift +++ b/Sources/WalletConnectSign/NetworkInteractor/NetworkInteractor.swift @@ -13,11 +13,6 @@ struct WCResponse: Codable { } protocol NetworkInteracting: AnyObject { - //// TODO - both methods to remove, use Publishers instead - var onPairingResponse: ((WCResponse) -> Void)? {get set} // Temporary workaround - var onResponse: ((WCResponse) -> Void)? {get set} - /////// - var transportConnectionPublisher: AnyPublisher {get} var wcRequestPublisher: AnyPublisher {get} var responsePublisher: AnyPublisher {get} @@ -41,31 +36,26 @@ extension NetworkInteracting { } class NetworkInteractor: NetworkInteracting { - - var onPairingResponse: ((WCResponse) -> Void)? - var onResponse: ((WCResponse) -> Void)? - private var publishers = [AnyCancellable]() + private var publishers = [AnyCancellable]() private var relayClient: NetworkRelaying private let serializer: Serializing private let jsonRpcHistory: JsonRpcHistoryRecording + private let transportConnectionPublisherSubject = PassthroughSubject() + private let responsePublisherSubject = PassthroughSubject() + private let wcRequestPublisherSubject = PassthroughSubject() + var transportConnectionPublisher: AnyPublisher { transportConnectionPublisherSubject.eraseToAnyPublisher() } - private let transportConnectionPublisherSubject = PassthroughSubject() - - //rename to request publisher var wcRequestPublisher: AnyPublisher { wcRequestPublisherSubject.eraseToAnyPublisher() } - private let wcRequestPublisherSubject = PassthroughSubject() - var responsePublisher: AnyPublisher { responsePublisherSubject.eraseToAnyPublisher() } - private let responsePublisherSubject = PassthroughSubject() let logger: ConsoleLogging @@ -184,12 +174,14 @@ class NetworkInteractor: NetworkInteracting { } //MARK: - Private + private func setUpPublishers() { relayClient.socketConnectionStatusPublisher.sink { [weak self] status in if status == .connected { self?.transportConnectionPublisherSubject.send() } }.store(in: &publishers) + relayClient.onMessage = { [unowned self] topic, message in manageSubscription(topic, message) } @@ -229,8 +221,6 @@ class NetworkInteractor: NetworkInteracting { requestParams: record.request.params, result: JsonRpcResult.response(response)) responsePublisherSubject.send(wcResponse) - onPairingResponse?(wcResponse) - onResponse?(wcResponse) } catch { logger.info("Info: \(error.localizedDescription)") } @@ -246,8 +236,6 @@ class NetworkInteractor: NetworkInteracting { requestParams: record.request.params, result: JsonRpcResult.error(response)) responsePublisherSubject.send(wcResponse) - onPairingResponse?(wcResponse) - onResponse?(wcResponse) } catch { logger.info("Info: \(error.localizedDescription)") } diff --git a/Sources/WalletConnectSign/Sign/Sign.swift b/Sources/WalletConnectSign/Sign/Sign.swift index 29b980dac..907e5d226 100644 --- a/Sources/WalletConnectSign/Sign/Sign.swift +++ b/Sources/WalletConnectSign/Sign/Sign.swift @@ -145,7 +145,7 @@ extension Sign { /// For the responder to approve a session proposal. /// - Parameters: - /// - proposal: Session Proposal received from peer client in a WalletConnect delegate function: `didReceive(sessionProposal: Session.Proposal)` + /// - proposalId: Session Proposal Public key received from peer client in a WalletConnect delegate function: `didReceive(sessionProposal: Session.Proposal)` /// - accounts: A Set of accounts that the dapp will be allowed to request methods executions on. /// - methods: A Set of methods that the dapp will be allowed to request. /// - events: A Set of events @@ -155,10 +155,10 @@ extension Sign { /// For the responder to reject a session proposal. /// - Parameters: - /// - proposal: Session Proposal received from peer client in a WalletConnect delegate. + /// - proposalId: Session Proposal Public key received from peer client in a WalletConnect delegate. /// - reason: Reason why the session proposal was rejected. Conforms to CAIP25. - public func reject(proposal: Session.Proposal, reason: RejectionReason) { - client.reject(proposal: proposal, reason: reason) + public func reject(proposalId: String, reason: RejectionReason) throws { + try client.reject(proposalId: proposalId, reason: reason) } /// For the responder to update session methods diff --git a/Sources/WalletConnectSign/Sign/SignClient.swift b/Sources/WalletConnectSign/Sign/SignClient.swift index b9ccd902e..54bc2b724 100644 --- a/Sources/WalletConnectSign/Sign/SignClient.swift +++ b/Sources/WalletConnectSign/Sign/SignClient.swift @@ -28,6 +28,7 @@ public final class SignClient { private let pairingEngine: PairingEngine private let pairEngine: PairEngine private let sessionEngine: SessionEngine + private let approveEngine: ApproveEngine private let nonControllerSessionStateMachine: NonControllerSessionStateMachine private let controllerSessionStateMachine: ControllerSessionStateMachine private let networkingInteractor: NetworkInteracting @@ -61,11 +62,13 @@ public final class SignClient { let pairingStore = PairingStorage(storage: SequenceStore(store: .init(defaults: keyValueStorage, identifier: StorageDomainIdentifiers.pairings.rawValue))) let sessionStore = SessionStorage(storage: SequenceStore(store: .init(defaults: keyValueStorage, identifier: StorageDomainIdentifiers.sessions.rawValue))) let sessionToPairingTopic = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: StorageDomainIdentifiers.sessionToPairingTopic.rawValue) - self.pairingEngine = PairingEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore, sessionToPairingTopic: sessionToPairingTopic, metadata: metadata, logger: logger) - self.sessionEngine = SessionEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore, sessionStore: sessionStore, sessionToPairingTopic: sessionToPairingTopic, metadata: metadata, logger: logger) + let proposalPayloadsStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: StorageDomainIdentifiers.proposals.rawValue) + self.pairingEngine = PairingEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore, metadata: metadata, logger: logger) + self.sessionEngine = SessionEngine(networkingInteractor: networkingInteractor, kms: kms, sessionStore: sessionStore, logger: logger) self.nonControllerSessionStateMachine = NonControllerSessionStateMachine(networkingInteractor: networkingInteractor, kms: kms, sessionStore: sessionStore, logger: logger) self.controllerSessionStateMachine = ControllerSessionStateMachine(networkingInteractor: networkingInteractor, kms: kms, sessionStore: sessionStore, logger: logger) self.pairEngine = PairEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore) + self.approveEngine = ApproveEngine(networkingInteractor: networkingInteractor, proposalPayloadsStore: proposalPayloadsStore, sessionToPairingTopic: sessionToPairingTopic, metadata: metadata, kms: kms, logger: logger, pairingStore: pairingStore, sessionStore: sessionStore) self.cleanupService = CleanupService(pairingStore: pairingStore, sessionStore: sessionStore, kms: kms, sessionToPairingTopic: sessionToPairingTopic) setUpConnectionObserving(relayClient: relayClient) setUpEnginesCallbacks() @@ -93,8 +96,10 @@ public final class SignClient { let pairingStore = PairingStorage(storage: SequenceStore(store: .init(defaults: keyValueStorage, identifier: StorageDomainIdentifiers.pairings.rawValue))) let sessionStore = SessionStorage(storage: SequenceStore(store: .init(defaults: keyValueStorage, identifier: StorageDomainIdentifiers.sessions.rawValue))) let sessionToPairingTopic = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: StorageDomainIdentifiers.sessionToPairingTopic.rawValue) - self.pairingEngine = PairingEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore, sessionToPairingTopic: sessionToPairingTopic, metadata: metadata, logger: logger) - self.sessionEngine = SessionEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore, sessionStore: sessionStore, sessionToPairingTopic: sessionToPairingTopic, metadata: metadata, logger: logger) + let proposalPayloadsStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: StorageDomainIdentifiers.proposals.rawValue) + self.pairingEngine = PairingEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore, metadata: metadata, logger: logger) + self.sessionEngine = SessionEngine(networkingInteractor: networkingInteractor, kms: kms, sessionStore: sessionStore, logger: logger) + self.approveEngine = ApproveEngine(networkingInteractor: networkingInteractor, proposalPayloadsStore: proposalPayloadsStore, sessionToPairingTopic: sessionToPairingTopic, metadata: metadata, kms: kms, logger: logger, pairingStore: pairingStore, sessionStore: sessionStore) self.nonControllerSessionStateMachine = NonControllerSessionStateMachine(networkingInteractor: networkingInteractor, kms: kms, sessionStore: sessionStore, logger: logger) self.controllerSessionStateMachine = ControllerSessionStateMachine(networkingInteractor: networkingInteractor, kms: kms, sessionStore: sessionStore, logger: logger) self.pairEngine = PairEngine(networkingInteractor: networkingInteractor, kms: kms, pairingStore: pairingStore) @@ -148,25 +153,21 @@ public final class SignClient { /// For the responder to approve a session proposal. /// - Parameters: - /// - proposal: Session Proposal received from peer client in a WalletConnect delegate function: `didReceive(sessionProposal: Session.Proposal)` + /// - proposalId: Session Proposal Public key received from peer client in a WalletConnect delegate function: `didReceive(sessionProposal: Session.Proposal)` /// - accounts: A Set of accounts that the dapp will be allowed to request methods executions on. /// - methods: A Set of methods that the dapp will be allowed to request. /// - events: A Set of events - public func approve( - proposalId: String, - namespaces: [String: SessionNamespace] - ) throws { + public func approve(proposalId: String, namespaces: [String: SessionNamespace]) throws { //TODO - accounts should be validated for matching namespaces BEFORE responding proposal - guard let (sessionTopic, proposal) = pairingEngine.approveProposal(proposerPubKey: proposalId, validating: namespaces) else {return} - try sessionEngine.settle(topic: sessionTopic, proposal: proposal, namespaces: namespaces) + try approveEngine.approveProposal(proposerPubKey: proposalId, validating: namespaces) } /// For the responder to reject a session proposal. /// - Parameters: - /// - proposal: Session Proposal received from peer client in a WalletConnect delegate. + /// - proposalId: Session Proposal Public key received from peer client in a WalletConnect delegate. /// - reason: Reason why the session proposal was rejected. Conforms to CAIP25. - public func reject(proposal: Session.Proposal, reason: RejectionReason) { - pairingEngine.reject(proposal: proposal.proposal, reason: reason.internalRepresentation()) + public func reject(proposalId: String, reason: RejectionReason) throws { + try approveEngine.reject(proposerPubKey: proposalId, reason: reason.internalRepresentation()) } /// For the responder to update session namespaces @@ -291,15 +292,15 @@ public final class SignClient { // MARK: - Private private func setUpEnginesCallbacks() { - sessionEngine.onSessionSettle = { [unowned self] settledSession in - delegate?.didSettle(session: settledSession) - } - pairingEngine.onSessionProposal = { [unowned self] proposal in + approveEngine.onSessionProposal = { [unowned self] proposal in delegate?.didReceive(sessionProposal: proposal) } - pairingEngine.onSessionRejected = { [unowned self] proposal, reason in + approveEngine.onSessionRejected = { [unowned self] proposal, reason in delegate?.didReject(proposal: proposal, reason: reason.publicRepresentation()) } + approveEngine.onSessionSettle = { [unowned self] settledSession in + delegate?.didSettle(session: settledSession) + } sessionEngine.onSessionRequest = { [unowned self] sessionRequest in delegate?.didReceive(sessionRequest: sessionRequest) } @@ -324,10 +325,6 @@ public final class SignClient { sessionEngine.onSessionResponse = { [unowned self] response in delegate?.didReceive(sessionResponse: response) } - pairingEngine.onProposeResponse = { [unowned self] sessionTopic, proposal in - sessionEngine.settlingProposal = proposal - sessionEngine.setSubscription(topic: sessionTopic) - } } #if DEBUG diff --git a/Tests/WalletConnectSignTests/SessionEngineTests.swift b/Tests/WalletConnectSignTests/ApproveEngineTests.swift similarity index 51% rename from Tests/WalletConnectSignTests/SessionEngineTests.swift rename to Tests/WalletConnectSignTests/ApproveEngineTests.swift index cabea8efa..a0222fb29 100644 --- a/Tests/WalletConnectSignTests/SessionEngineTests.swift +++ b/Tests/WalletConnectSignTests/ApproveEngineTests.swift @@ -1,55 +1,87 @@ import XCTest -import WalletConnectUtils -@testable import TestingUtils -import WalletConnectKMS +import Combine @testable import WalletConnectSign +@testable import TestingUtils +@testable import WalletConnectKMS +import WalletConnectUtils -extension Collection where Self.Element == String { - func toAccountSet() -> Set { - Set(self.map { Account($0)! }) - } -} - -final class SessionEngineTests: XCTestCase { +final class ApproveEngineTests: XCTestCase { - var engine: SessionEngine! - + var engine: ApproveEngine! + var metadata: AppMetadata! var networkingInteractor: MockedWCRelay! - var storageMock: WCSessionStorageMock! var cryptoMock: KeyManagementServiceMock! + var pairingStorageMock: WCPairingStorageMock! + var sessionStorageMock: WCSessionStorageMock! + var proposalPayloadsStore: CodableStore! - var topicGenerator: TopicGenerator! - - var metadata: AppMetadata! + var publishers = Set() override func setUp() { + metadata = AppMetadata.stub() networkingInteractor = MockedWCRelay() - storageMock = WCSessionStorageMock() cryptoMock = KeyManagementServiceMock() - topicGenerator = TopicGenerator() - setupEngine() + pairingStorageMock = WCPairingStorageMock() + sessionStorageMock = WCSessionStorageMock() + proposalPayloadsStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "") + engine = ApproveEngine( + networkingInteractor: networkingInteractor, + proposalPayloadsStore: proposalPayloadsStore, + sessionToPairingTopic: CodableStore(defaults: RuntimeKeyValueStorage(), identifier: ""), + metadata: metadata, + kms: cryptoMock, + logger: ConsoleLoggerMock(), + pairingStore: pairingStorageMock, + sessionStore: sessionStorageMock + ) } - + override func tearDown() { networkingInteractor = nil - storageMock = nil + metadata = nil cryptoMock = nil - topicGenerator = nil + pairingStorageMock = nil engine = nil } - func setupEngine() { - metadata = AppMetadata.stub() - let logger = ConsoleLoggerMock() - engine = SessionEngine( - networkingInteractor: networkingInteractor, - kms: cryptoMock, - pairingStore: WCPairingStorageMock(), - sessionStore: storageMock, - sessionToPairingTopic: CodableStore(defaults: RuntimeKeyValueStorage(), identifier: ""), - metadata: metadata, - logger: logger, - topicGenerator: topicGenerator.getTopic) + @MainActor + func testApproveProposal() async throws { + // Client receives a proposal + let topicA = String.generateTopic() + let proposerPubKey = AgreementPrivateKey().publicKey.hexRepresentation + let proposal = SessionProposal.stub(proposerPubKey: proposerPubKey) + let request = WCRequest(method: .sessionPropose, params: .sessionPropose(proposal)) + let payload = WCRequestSubscriptionPayload(topic: topicA, wcRequest: request) + networkingInteractor.wcRequestPublisherSubject.send(payload) + + try engine.approveProposal(proposerPubKey: proposal.proposer.publicKey, validating: SessionNamespace.stubDictionary()) + + usleep(100) + + let topicB = networkingInteractor.subscriptions.last! + + XCTAssertTrue(networkingInteractor.didCallSubscribe) + XCTAssert(cryptoMock.hasAgreementSecret(for: topicB), "Responder must store agreement key for topic B") + XCTAssertEqual(networkingInteractor.didRespondOnTopic!, topicA, "Responder must respond on topic A") + } + + func testReceiveProposal() { + let pairing = WCPairing.stub() + let topicA = pairing.topic + pairingStorageMock.setPairing(pairing) + var sessionProposed = false + let proposerPubKey = AgreementPrivateKey().publicKey.hexRepresentation + let proposal = SessionProposal.stub(proposerPubKey: proposerPubKey) + let request = WCRequest(method: .sessionPropose, params: .sessionPropose(proposal)) + let payload = WCRequestSubscriptionPayload(topic: topicA, wcRequest: request) + + engine.onSessionProposal = { _ in + sessionProposed = true + } + + networkingInteractor.wcRequestPublisherSubject.send(payload) + XCTAssertNotNil(try! proposalPayloadsStore.get(key: proposal.proposer.publicKey), "Proposer must store proposal payload") + XCTAssertTrue(sessionProposed) } func testSessionSettle() { @@ -59,7 +91,7 @@ final class SessionEngineTests: XCTestCase { let proposal = SessionProposal.stub(proposerPubKey: AgreementPrivateKey().publicKey.hexRepresentation) try? engine.settle(topic: topicB, proposal: proposal, namespaces: SessionNamespace.stubDictionary()) usleep(100) - XCTAssertTrue(storageMock.hasSession(forTopic: topicB), "Responder must persist session on topic B") + XCTAssertTrue(sessionStorageMock.hasSession(forTopic: topicB), "Responder must persist session on topic B") XCTAssert(networkingInteractor.didSubscribe(to: topicB), "Responder must subscribe for topic B") XCTAssertTrue(networkingInteractor.didCallRequest, "Responder must send session settle payload on topic B") } @@ -75,18 +107,14 @@ final class SessionEngineTests: XCTestCase { engine.settlingProposal = SessionProposal.stub() networkingInteractor.wcRequestPublisherSubject.send(WCRequestSubscriptionPayload.stubSettle(topic: sessionTopic)) - XCTAssertTrue(storageMock.getSession(forTopic: sessionTopic)!.acknowledged, "Proposer must store acknowledged session on topic B") + XCTAssertTrue(sessionStorageMock.getSession(forTopic: sessionTopic)!.acknowledged, "Proposer must store acknowledged session on topic B") XCTAssertTrue(networkingInteractor.didRespondSuccess, "Proposer must send acknowledge on settle request") XCTAssertTrue(didCallBackOnSessionApproved, "Proposer's engine must call back with session") } func testHandleSessionSettleAcknowledge() { let session = WCSession.stub(isSelfController: true, acknowledged: false) - storageMock.setSession(session) - var didCallBackOnSessionApproved = false - engine.onSessionSettle = { _ in - didCallBackOnSessionApproved = true - } + sessionStorageMock.setSession(session) let settleResponse = JSONRPCResponse(id: 1, result: AnyCodable(true)) let response = WCResponse( @@ -95,15 +123,15 @@ final class SessionEngineTests: XCTestCase { requestMethod: .sessionSettle, requestParams: .sessionSettle(SessionType.SettleParams.stub()), result: .response(settleResponse)) - networkingInteractor.onResponse?(response) + networkingInteractor.responsePublisherSubject.send(response) - XCTAssertTrue(storageMock.getSession(forTopic: session.topic)!.acknowledged, "Responder must acknowledged session") + XCTAssertTrue(sessionStorageMock.getSession(forTopic: session.topic)!.acknowledged, "Responder must acknowledged session") } func testHandleSessionSettleError() { let privateKey = AgreementPrivateKey() let session = WCSession.stub(isSelfController: false, selfPrivateKey: privateKey, acknowledged: false) - storageMock.setSession(session) + sessionStorageMock.setSession(session) cryptoMock.setAgreementSecret(AgreementKeys.stub(), topic: session.topic) try! cryptoMock.setPrivateKey(privateKey) @@ -113,9 +141,9 @@ final class SessionEngineTests: XCTestCase { requestMethod: .sessionSettle, requestParams: .sessionSettle(SessionType.SettleParams.stub()), result: .error(JSONRPCErrorResponse(id: 1, error: JSONRPCErrorResponse.Error(code: 0, message: "")))) - networkingInteractor.onResponse?(response) + networkingInteractor.responsePublisherSubject.send(response) - XCTAssertNil(storageMock.getSession(forTopic: session.topic), "Responder must remove session") + XCTAssertNil(sessionStorageMock.getSession(forTopic: session.topic), "Responder must remove session") XCTAssertTrue(networkingInteractor.didUnsubscribe(to: session.topic), "Responder must unsubscribe topic B") XCTAssertFalse(cryptoMock.hasAgreementSecret(for: session.topic), "Responder must remove agreement secret") XCTAssertFalse(cryptoMock.hasPrivateKey(for: session.self.publicKey!), "Responder must remove private key") diff --git a/Tests/WalletConnectSignTests/Mocks/MockedRelay.swift b/Tests/WalletConnectSignTests/Mocks/MockedRelay.swift index 9b23b9592..40ca53503 100644 --- a/Tests/WalletConnectSignTests/Mocks/MockedRelay.swift +++ b/Tests/WalletConnectSignTests/Mocks/MockedRelay.swift @@ -7,28 +7,22 @@ import WalletConnectUtils class MockedWCRelay: NetworkInteracting { - let responsePublisherSubject = PassthroughSubject() private(set) var subscriptions: [String] = [] private(set) var unsubscriptions: [String] = [] - var responsePublisher: AnyPublisher { - responsePublisherSubject.eraseToAnyPublisher() - } - - var onPairingResponse: ((WCResponse) -> Void)? - var onResponse: ((WCResponse) -> Void)? - - var onPairingApproveResponse: ((String) -> Void)? + let transportConnectionPublisherSubject = PassthroughSubject() + let responsePublisherSubject = PassthroughSubject() + let wcRequestPublisherSubject = PassthroughSubject() var transportConnectionPublisher: AnyPublisher { transportConnectionPublisherSubject.eraseToAnyPublisher() } - private let transportConnectionPublisherSubject = PassthroughSubject() - - let wcRequestPublisherSubject = PassthroughSubject() var wcRequestPublisher: AnyPublisher { wcRequestPublisherSubject.eraseToAnyPublisher() } + var responsePublisher: AnyPublisher { + responsePublisherSubject.eraseToAnyPublisher() + } var didCallSubscribe = false var didRespondOnTopic: String? = nil diff --git a/Tests/WalletConnectSignTests/PairingEngineTests.swift b/Tests/WalletConnectSignTests/PairingEngineTests.swift index 26177b51a..bc08fcfdd 100644 --- a/Tests/WalletConnectSignTests/PairingEngineTests.swift +++ b/Tests/WalletConnectSignTests/PairingEngineTests.swift @@ -1,4 +1,5 @@ import XCTest +import Combine @testable import WalletConnectSign @testable import TestingUtils @testable import WalletConnectKMS @@ -12,21 +13,21 @@ func deriveTopic(publicKey: String, privateKey: AgreementPrivateKey) -> String { final class PairingEngineTests: XCTestCase { var engine: PairingEngine! + var approveEngine: ApproveEngine! var networkingInteractor: MockedWCRelay! var storageMock: WCPairingStorageMock! var cryptoMock: KeyManagementServiceMock! - var proposalPayloadsStore: CodableStore! var topicGenerator: TopicGenerator! + var publishers = Set() override func setUp() { networkingInteractor = MockedWCRelay() storageMock = WCPairingStorageMock() cryptoMock = KeyManagementServiceMock() topicGenerator = TopicGenerator() - proposalPayloadsStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "") - setupEngine() + setupEngines() } override func tearDown() { @@ -35,20 +36,30 @@ final class PairingEngineTests: XCTestCase { cryptoMock = nil topicGenerator = nil engine = nil + approveEngine = nil } - func setupEngine() { + func setupEngines() { let meta = AppMetadata.stub() let logger = ConsoleLoggerMock() engine = PairingEngine( networkingInteractor: networkingInteractor, kms: cryptoMock, pairingStore: storageMock, + metadata: meta, + logger: logger, + topicGenerator: topicGenerator.getTopic + ) + approveEngine = ApproveEngine( + networkingInteractor: networkingInteractor, + proposalPayloadsStore: .init(defaults: RuntimeKeyValueStorage(), identifier: ""), sessionToPairingTopic: CodableStore(defaults: RuntimeKeyValueStorage(), identifier: ""), metadata: meta, + kms: cryptoMock, logger: logger, - topicGenerator: topicGenerator.getTopic, - proposalPayloadsStore: proposalPayloadsStore) + pairingStore: storageMock, + sessionStore: WCSessionStorageMock() + ) } func testCreate() async { @@ -75,37 +86,7 @@ final class PairingEngineTests: XCTestCase { XCTAssertEqual(publishTopic, topicA) } - func testReceiveProposal() { - let pairing = WCPairing.stub() - let topicA = pairing.topic - storageMock.setPairing(pairing) - var sessionProposed = false - let proposerPubKey = AgreementPrivateKey().publicKey.hexRepresentation - let proposal = SessionProposal.stub(proposerPubKey: proposerPubKey) - let request = WCRequest(method: .sessionPropose, params: .sessionPropose(proposal)) - let payload = WCRequestSubscriptionPayload(topic: topicA, wcRequest: request) - engine.onSessionProposal = { _ in - sessionProposed = true - } - networkingInteractor.wcRequestPublisherSubject.send(payload) - XCTAssertNotNil(try! proposalPayloadsStore.get(key: proposal.proposer.publicKey), "Proposer must store proposal payload") - XCTAssertTrue(sessionProposed) - } - - func testRespondProposal() { - // Client receives a proposal - let topicA = String.generateTopic() - let proposerPubKey = AgreementPrivateKey().publicKey.hexRepresentation - let proposal = SessionProposal.stub(proposerPubKey: proposerPubKey) - let request = WCRequest(method: .sessionPropose, params: .sessionPropose(proposal)) - let payload = WCRequestSubscriptionPayload(topic: topicA, wcRequest: request) - networkingInteractor.wcRequestPublisherSubject.send(payload) - let (topicB, _) = engine.approveProposal(proposerPubKey: proposal.proposer.publicKey, validating: SessionNamespace.stubDictionary())! - - XCTAssert(cryptoMock.hasAgreementSecret(for: topicB), "Responder must store agreement key for topic B") - XCTAssertEqual(networkingInteractor.didRespondOnTopic!, topicA, "Responder must respond on topic A") - } - + @MainActor func testHandleSessionProposeResponse() async { let uri = try! await engine.create() let pairing = storageMock.getPairing(forTopic: uri.topic)! @@ -131,18 +112,14 @@ final class PairingEngineTests: XCTestCase { requestMethod: request.method, requestParams: request.params, result: .response(jsonRpcResponse)) - - var sessionTopic: String! - - engine.onProposeResponse = { topic, _ in - sessionTopic = topic - } - networkingInteractor.onPairingResponse?(response) + + networkingInteractor.responsePublisherSubject.send(response) let privateKey = try! cryptoMock.getPrivateKey(for: proposal.proposer.publicKey)! let topicB = deriveTopic(publicKey: responder.publicKey, privateKey: privateKey) - let storedPairing = storageMock.getPairing(forTopic: topicA)! + let sessionTopic = networkingInteractor.subscriptions.last! + XCTAssertTrue(networkingInteractor.didCallSubscribe) XCTAssert(storedPairing.active) XCTAssertEqual(topicB, sessionTopic, "Responder engine calls back with session topic") } @@ -163,7 +140,7 @@ final class PairingEngineTests: XCTestCase { } let response = WCResponse.stubError(forRequest: request, topic: topicA) - networkingInteractor.onPairingResponse?(response) + networkingInteractor.responsePublisherSubject.send(response) XCTAssert(networkingInteractor.didUnsubscribe(to: pairing.topic), "Proposer must unsubscribe if pairing is inactive.") XCTAssertFalse(storageMock.hasPairing(forTopic: pairing.topic), "Proposer must delete an inactive pairing.") @@ -191,7 +168,7 @@ final class PairingEngineTests: XCTestCase { storageMock.setPairing(storedPairing) let response = WCResponse.stubError(forRequest: request, topic: topicA) - networkingInteractor.onPairingResponse?(response) + networkingInteractor.responsePublisherSubject.send(response) XCTAssertFalse(networkingInteractor.didUnsubscribe(to: pairing.topic), "Proposer must not unsubscribe if pairing is active.") XCTAssert(storageMock.hasPairing(forTopic: pairing.topic), "Proposer must not delete an active pairing.")