diff --git a/Example/IntegrationTests/Auth/AuthTests.swift b/Example/IntegrationTests/Auth/AuthTests.swift index 566fc1761..8e343268f 100644 --- a/Example/IntegrationTests/Auth/AuthTests.swift +++ b/Example/IntegrationTests/Auth/AuthTests.swift @@ -35,7 +35,6 @@ final class AuthTests: XCTestCase { wait(for: [expectation], timeout: 5) } - func makeClient(prefix: String, account: Account? = nil) -> AuthClient { let logger = ConsoleLogger(suffix: prefix, loggingLevel: .debug) let relayHost = "relay.walletconnect.com" @@ -70,7 +69,7 @@ final class AuthTests: XCTestCase { Task(priority: .high) { let signature = try! MessageSigner().sign(message: message, privateKey: prvKey) let cacaoSignature = CacaoSignature(t: "eip191", s: signature) - try! await wallet.respond(.success(RespondParams(id: id, signature: cacaoSignature))) + try! await wallet.respond(requestId: id, result: .success(cacaoSignature)) } } .store(in: &publishers) @@ -79,10 +78,10 @@ final class AuthTests: XCTestCase { responseExpectation.fulfill() } .store(in: &publishers) - wait(for: [responseExpectation], timeout: 2) + wait(for: [responseExpectation], timeout: 5) } - func testRespondInvalidSignature() async { + func testRespondSignatureVerificationFailed() async { let responseExpectation = expectation(description: "invalid signature response delivered") let uri = try! await app.request(RequestParams.stub()) try! await wallet.pair(uri: uri) @@ -90,13 +89,13 @@ final class AuthTests: XCTestCase { Task(priority: .high) { let invalidSignature = "438effc459956b57fcd9f3dac6c675f9cee88abf21acab7305e8e32aa0303a883b06dcbd956279a7a2ca21ffa882ff55cc22e8ab8ec0f3fe90ab45f306938cfa1b" let cacaoSignature = CacaoSignature(t: "eip191", s: invalidSignature) - try! await wallet.respond(.success(RespondParams(id: id, signature: cacaoSignature))) + try! await wallet.respond(requestId: id, result: .success(cacaoSignature)) } } .store(in: &publishers) app.authResponsePublisher.sink { (id, result) in guard case .failure(let error) = result else { XCTFail(); return } - // TODO - complete after reason codes are merged + XCTAssertEqual(error, .signatureVerificationFailed) responseExpectation.fulfill() } .store(in: &publishers) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 27ff070d6..ad4192861 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -15,8 +15,8 @@ public class AuthClient { authRequestPublisherSubject.eraseToAnyPublisher() } - private var authResponsePublisherSubject = PassthroughSubject<(id: RPCID, result: Result), Never>() - public var authResponsePublisher: AnyPublisher<(id: RPCID, result: Result), Never> { + private var authResponsePublisherSubject = PassthroughSubject<(id: RPCID, result: Result), Never>() + public var authResponsePublisher: AnyPublisher<(id: RPCID, result: Result), Never> { authResponsePublisherSubject.eraseToAnyPublisher() } @@ -87,9 +87,9 @@ public class AuthClient { try await appRequestService.request(params: params, topic: topic) } - public func respond(_ result: Result) async throws { + public func respond(requestId: RPCID, result: Result) async throws { guard let account = account else { throw Errors.unknownWalletAddress } - try await walletRespondService.respond(result: result, account: account) + try await walletRespondService.respond(requestId: requestId, result: result, account: account) } public func getPendingRequests() throws -> [AuthRequest] { diff --git a/Sources/Auth/AuthClientFactory.swift b/Sources/Auth/AuthClientFactory.swift index cf75c3ce1..6be615528 100644 --- a/Sources/Auth/AuthClientFactory.swift +++ b/Sources/Auth/AuthClientFactory.swift @@ -26,7 +26,7 @@ public struct AuthClientFactory { let messageSigner = MessageSigner(signer: Signer()) let appRespondSubscriber = AppRespondSubscriber(networkingInteractor: networkingInteractor, logger: logger, rpcHistory: history, signatureVerifier: messageSigner, messageFormatter: messageFormatter) let walletPairService = WalletPairService(networkingInteractor: networkingInteractor, kms: kms, pairingStorage: pairingStore) - let walletRequestSubscriber = WalletRequestSubscriber(networkingInteractor: networkingInteractor, logger: logger, messageFormatter: messageFormatter, address: account?.address) + let walletRequestSubscriber = WalletRequestSubscriber(networkingInteractor: networkingInteractor, logger: logger, kms: kms, messageFormatter: messageFormatter, address: account?.address) let walletRespondService = WalletRespondService(networkingInteractor: networkingInteractor, logger: logger, kms: kms, rpcHistory: history) let pendingRequestsProvider = PendingRequestsProvider(rpcHistory: history) let cleanupService = CleanupService(pairingStore: pairingStore, kms: kms) diff --git a/Sources/Auth/Services/App/AppRespondSubscriber.swift b/Sources/Auth/Services/App/AppRespondSubscriber.swift index cba492bf3..bc7e13c72 100644 --- a/Sources/Auth/Services/App/AppRespondSubscriber.swift +++ b/Sources/Auth/Services/App/AppRespondSubscriber.swift @@ -10,7 +10,8 @@ class AppRespondSubscriber { private let signatureVerifier: MessageSignatureVerifying private let messageFormatter: SIWEMessageFormatting private var publishers = [AnyCancellable]() - var onResponse: ((_ id: RPCID, _ result: Result) -> Void)? + + var onResponse: ((_ id: RPCID, _ result: Result) -> Void)? init(networkingInteractor: NetworkInteracting, logger: ConsoleLogging, @@ -26,7 +27,6 @@ class AppRespondSubscriber { } private func subscribeForResponse() { - // TODO - handle error response networkingInteractor.responsePublisher.sink { [unowned self] subscriptionPayload in guard let requestId = subscriptionPayload.response.id, @@ -36,30 +36,23 @@ class AppRespondSubscriber { networkingInteractor.unsubscribe(topic: subscriptionPayload.topic) - do { - guard let cacao = try subscriptionPayload.response.result?.get(Cacao.self) else { - return logger.debug("Malformed auth response params") - } + guard + let cacao = try? subscriptionPayload.response.result?.get(Cacao.self), + let address = try? DIDPKH(iss: cacao.payload.iss).account.address, + let message = try? messageFormatter.formatMessage(from: cacao.payload) + else { self.onResponse?(requestId, .failure(.malformedResponseParams)); return } + + guard let requestPayload = try? requestParams.get(AuthRequestParams.self) + else { self.onResponse?(requestId, .failure(.malformedRequestParams)); return } + + guard messageFormatter.formatMessage(from: requestPayload.payloadParams, address: address) == message + else { self.onResponse?(requestId, .failure(.messageCompromised)); return } - let requestPayload = try requestParams.get(AuthRequestParams.self) - let address = try DIDPKH(iss: cacao.payload.iss).account.address - let message = try messageFormatter.formatMessage(from: cacao.payload) - let originalMessage = messageFormatter.formatMessage(from: requestPayload.payloadParams, address: address) + guard let _ = try? signatureVerifier.verify(signature: cacao.signature.s, message: message, address: address) + else { self.onResponse?(requestId, .failure(.signatureVerificationFailed)); return } - guard originalMessage == message else { - return logger.debug("Original message compromised") - } + onResponse?(requestId, .success(cacao)) - try signatureVerifier.verify( - signature: cacao.signature.s, - message: message, - address: address - ) - logger.debug("Received response with valid signature") - onResponse?(requestId, .success(cacao)) - } catch { - logger.debug("Received response with invalid signature") - } }.store(in: &publishers) } } diff --git a/Sources/Auth/Services/Common/NetworkingInteractor.swift b/Sources/Auth/Services/Common/NetworkingInteractor.swift index 62d152ccb..24419b295 100644 --- a/Sources/Auth/Services/Common/NetworkingInteractor.swift +++ b/Sources/Auth/Services/Common/NetworkingInteractor.swift @@ -13,6 +13,7 @@ protocol NetworkInteracting { func request(_ request: RPCRequest, topic: String, tag: Int, envelopeType: Envelope.EnvelopeType) async throws func requestNetworkAck(_ request: RPCRequest, topic: String, tag: Int) async throws func respond(topic: String, response: RPCResponse, tag: Int, envelopeType: Envelope.EnvelopeType) async throws + func respondError(topic: String, requestId: RPCID, tag: Int, reason: Reason, envelopeType: Envelope.EnvelopeType) async throws } extension NetworkInteracting { @@ -27,14 +28,17 @@ class NetworkingInteractor: NetworkInteracting { private let serializer: Serializing private let rpcHistory: RPCHistory private let logger: ConsoleLogging + + private let requestPublisherSubject = PassthroughSubject() var requestPublisher: AnyPublisher { requestPublisherSubject.eraseToAnyPublisher() } - private let requestPublisherSubject = PassthroughSubject() + + private let responsePublisherSubject = PassthroughSubject() var responsePublisher: AnyPublisher { responsePublisherSubject.eraseToAnyPublisher() } - private let responsePublisherSubject = PassthroughSubject() + var socketConnectionStatusPublisher: AnyPublisher init(relayClient: RelayClient, @@ -99,11 +103,18 @@ class NetworkingInteractor: NetworkInteracting { try await relayClient.publish(topic: topic, payload: message, tag: tag) } + func respondError(topic: String, requestId: RPCID, tag: Int, reason: Reason, envelopeType: Envelope.EnvelopeType) async throws { + let error = JSONRPCError(code: reason.code, message: reason.message) + let response = RPCResponse(id: requestId, error: error) + let message = try! serializer.serialize(topic: topic, encodable: response, envelopeType: envelopeType) + try await relayClient.publish(topic: topic, payload: message, tag: tag) + } + private func manageSubscription(_ topic: String, _ encodedEnvelope: String) { if let deserializedJsonRpcRequest: RPCRequest = serializer.tryDeserialize(topic: topic, encodedEnvelope: encodedEnvelope) { handleRequest(topic: topic, request: deserializedJsonRpcRequest) - } else if let deserializedJsonRpcResponse: RPCResponse = serializer.tryDeserialize(topic: topic, encodedEnvelope: encodedEnvelope) { - handleResponse(response: deserializedJsonRpcResponse) + } else if let response: RPCResponse = serializer.tryDeserialize(topic: topic, encodedEnvelope: encodedEnvelope) { + handleResponse(response: response) } else { logger.debug("Networking Interactor - Received unknown object type from networking relay") } diff --git a/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift b/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift index aa5cdde47..6d04629ca 100644 --- a/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift +++ b/Sources/Auth/Services/Wallet/WalletRequestSubscriber.swift @@ -2,10 +2,12 @@ import Combine import Foundation import WalletConnectUtils import JSONRPC +import WalletConnectKMS class WalletRequestSubscriber { private let networkingInteractor: NetworkInteracting private let logger: ConsoleLogging + private let kms: KeyManagementServiceProtocol private let address: String? private var publishers = [AnyCancellable]() private let messageFormatter: SIWEMessageFormatting @@ -13,37 +15,45 @@ class WalletRequestSubscriber { init(networkingInteractor: NetworkInteracting, logger: ConsoleLogging, + kms: KeyManagementServiceProtocol, messageFormatter: SIWEMessageFormatting, address: String?) { self.networkingInteractor = networkingInteractor self.logger = logger + self.kms = kms self.address = address self.messageFormatter = messageFormatter subscribeForRequest() } private func subscribeForRequest() { - guard let address = address else {return} - networkingInteractor.requestPublisher.sink { [unowned self] subscriptionPayload in + guard let address = address else { return } + + networkingInteractor.requestPublisher.sink { [unowned self] payload in + logger.debug("WalletRequestSubscriber: Received request") - guard - let requestId = subscriptionPayload.request.id, - subscriptionPayload.request.method == "wc_authRequest" else { return } - - do { - guard let authRequestParams = try subscriptionPayload.request.params?.get(AuthRequestParams.self) else { return logger.debug("Malformed auth request params") - } - - let message = messageFormatter.formatMessage( - from: authRequestParams.payloadParams, - address: address - ) - - onRequest?(requestId, message) - } catch { - logger.debug(error) - } + + guard let requestId = payload.request.id, payload.request.method == "wc_authRequest" + else { return } + + guard let authRequestParams = try? payload.request.params?.get(AuthRequestParams.self) + else { return respondError(.malformedRequestParams, topic: payload.topic, requestId: requestId) } + + let message = messageFormatter.formatMessage(from: authRequestParams.payloadParams, address: address) + + onRequest?(requestId, message) }.store(in: &publishers) } + private func respondError(_ error: AuthError, topic: String, requestId: RPCID) { + guard let pubKey = kms.getAgreementSecret(for: topic)?.publicKey + else { return logger.error("Agreement key for topic \(topic) not found") } + + let tag = AuthResponseParams.tag + let envelopeType = Envelope.EnvelopeType.type1(pubKey: pubKey.rawRepresentation) + + Task(priority: .high) { + try await networkingInteractor.respondError(topic: topic, requestId: requestId, tag: tag, reason: error, envelopeType: envelopeType) + } + } } diff --git a/Sources/Auth/Services/Wallet/WalletRespondService.swift b/Sources/Auth/Services/Wallet/WalletRespondService.swift index 6b5030df9..0aba57f14 100644 --- a/Sources/Auth/Services/Wallet/WalletRespondService.swift +++ b/Sources/Auth/Services/Wallet/WalletRespondService.swift @@ -22,30 +22,55 @@ actor WalletRespondService { self.kms = kms self.rpcHistory = rpcHistory } - - func respond(result: Result, account: Account) async throws { + + func respond(requestId: RPCID, result: Result, account: Account) async throws { switch result { - case .success(let params): - try await respond(respondParams: params, account: account) - case .failure(let error): - fatalError("TODO respond with error") + case .success(let signature): + try await respond(requestId: requestId, signature: signature, account: account) + case .failure: + try await respondError(requestId: requestId) } } - private func respond(respondParams: RespondParams, account: Account) async throws { - guard let request = rpcHistory.get(recordId: respondParams.id)?.request else { throw Errors.recordForIdNotFound } - guard let authRequestParams = try? request.params?.get(AuthRequestParams.self) else { throw Errors.malformedAuthRequestParams } + private func respond(requestId: RPCID, signature: CacaoSignature, account: Account) async throws { + let authRequestParams = try getAuthRequestParams(requestId: requestId) + let (topic, keys) = try generateAgreementKeys(requestParams: authRequestParams) - let peerPubKey = try AgreementPublicKey(hex: authRequestParams.requester.publicKey) - let responseTopic = peerPubKey.rawRepresentation.sha256().toHexString() - let selfPubKey = try kms.createX25519KeyPair() - let agreementKeys = try kms.performKeyAgreement(selfPublicKey: selfPubKey, peerPublicKey: peerPubKey.hexRepresentation) - try kms.setAgreementSecret(agreementKeys, topic: responseTopic) + try kms.setAgreementSecret(keys, topic: topic) let didpkh = DIDPKH(account: account) - let cacao = CacaoFormatter().format(authRequestParams, respondParams.signature, didpkh) - let response = RPCResponse(id: request.id!, result: cacao) + let cacao = CacaoFormatter().format(authRequestParams, signature, didpkh) + let response = RPCResponse(id: requestId, result: cacao) + try await networkingInteractor.respond(topic: topic, response: response, tag: AuthResponseParams.tag, envelopeType: .type1(pubKey: keys.publicKey.rawRepresentation)) + } + + private func respondError(requestId: RPCID) async throws { + let authRequestParams = try getAuthRequestParams(requestId: requestId) + let (topic, keys) = try generateAgreementKeys(requestParams: authRequestParams) + + try kms.setAgreementSecret(keys, topic: topic) + + let tag = AuthResponseParams.tag + let error = AuthError.userRejeted + let envelopeType = Envelope.EnvelopeType.type1(pubKey: keys.publicKey.rawRepresentation) + try await networkingInteractor.respondError(topic: topic, requestId: requestId, tag: tag, reason: error, envelopeType: envelopeType) + } + + private func getAuthRequestParams(requestId: RPCID) throws -> AuthRequestParams { + guard let request = rpcHistory.get(recordId: requestId)?.request + else { throw Errors.recordForIdNotFound } + + guard let authRequestParams = try request.params?.get(AuthRequestParams.self) + else { throw Errors.malformedAuthRequestParams } - try await networkingInteractor.respond(topic: responseTopic, response: response, tag: AuthResponseParams.tag, envelopeType: .type1(pubKey: selfPubKey.rawRepresentation)) + return authRequestParams + } + + private func generateAgreementKeys(requestParams: AuthRequestParams) throws -> (topic: String, keys: AgreementKeys) { + let peerPubKey = try AgreementPublicKey(hex: requestParams.requester.publicKey) + let topic = peerPubKey.rawRepresentation.sha256().toHexString() + let selfPubKey = try kms.createX25519KeyPair() + let keys = try kms.performKeyAgreement(selfPublicKey: selfPubKey, peerPublicKey: peerPubKey.hexRepresentation) + return (topic, keys) } } diff --git a/Sources/Auth/Types/ErrorCode.swift b/Sources/Auth/Types/ErrorCode.swift deleted file mode 100644 index e56498149..000000000 --- a/Sources/Auth/Types/ErrorCode.swift +++ /dev/null @@ -1,7 +0,0 @@ - -import Foundation - -public struct ErrorCode: Codable, Equatable, Error { - let code: Int - let message: String -} diff --git a/Sources/Auth/Types/Errors/AuthError.swift b/Sources/Auth/Types/Errors/AuthError.swift new file mode 100644 index 000000000..2b83d6182 --- /dev/null +++ b/Sources/Auth/Types/Errors/AuthError.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum AuthError: Codable, Equatable, Error { + case userRejeted + case malformedResponseParams + case malformedRequestParams + case messageCompromised + case signatureVerificationFailed +} + +extension AuthError: Reason { + + public var code: Int { + switch self { + case .userRejeted: + return 14001 + case .malformedResponseParams: + return 12001 + case .malformedRequestParams: + return 12002 + case .messageCompromised: + return 12003 + case .signatureVerificationFailed: + return 12004 + } + } + + public var message: String { + switch self { + case .userRejeted: + return "Auth request rejected by user" + case .malformedResponseParams: + return "Response params malformed" + case .malformedRequestParams: + return "Request params malformed" + case .messageCompromised: + return "Original message compromised" + case .signatureVerificationFailed: + return "Message verification failed" + } + } +} diff --git a/Sources/Auth/Types/Errors/Reason.swift b/Sources/Auth/Types/Errors/Reason.swift new file mode 100644 index 000000000..bbf491fdd --- /dev/null +++ b/Sources/Auth/Types/Errors/Reason.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol Reason { + var code: Int { get } + var message: String { get } +} + diff --git a/Sources/JSONRPC/RPCResponse.swift b/Sources/JSONRPC/RPCResponse.swift index e0e60f06f..ad6ba9ca6 100644 --- a/Sources/JSONRPC/RPCResponse.swift +++ b/Sources/JSONRPC/RPCResponse.swift @@ -47,6 +47,10 @@ public struct RPCResponse: Equatable { self.init(id: id, outcome: .success(AnyCodable(result))) } + public init(id: RPCID?, error: JSONRPCError) { + self.init(id: id, outcome: .failure(error)) + } + public init(id: Int64, error: JSONRPCError) { self.init(id: RPCID(id), outcome: .failure(error)) } diff --git a/Tests/AuthTests/AuthRequstSubscriberTests.swift b/Tests/AuthTests/AuthRequstSubscriberTests.swift index dbf7bba20..fc7c6ebbb 100644 --- a/Tests/AuthTests/AuthRequstSubscriberTests.swift +++ b/Tests/AuthTests/AuthRequstSubscriberTests.swift @@ -16,8 +16,9 @@ class AuthRequstSubscriberTests: XCTestCase { networkingInteractor = NetworkingInteractorMock() messageFormatter = SIWEMessageFormatterMock() sut = WalletRequestSubscriber(networkingInteractor: networkingInteractor, - logger: ConsoleLoggerMock(), - messageFormatter: messageFormatter, address: "") + logger: ConsoleLoggerMock(), + kms: KeyManagementServiceMock(), + messageFormatter: messageFormatter, address: "") } func testSubscribeRequest() { diff --git a/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift b/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift index 838b47ea7..a499ea4af 100644 --- a/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift +++ b/Tests/AuthTests/Mocks/NetworkingInteractorMock.swift @@ -32,6 +32,10 @@ struct NetworkingInteractorMock: NetworkInteracting { } + func respondError(topic: String, requestId: RPCID, tag: Int, reason: Reason, envelopeType: Envelope.EnvelopeType) async throws { + + } + func requestNetworkAck(_ request: RPCRequest, topic: String, tag: Int) async throws { } diff --git a/Tests/WalletConnectSignTests/WCPairingTests.swift b/Tests/WalletConnectSignTests/WCPairingTests.swift index 6826add6d..ca6872e24 100644 --- a/Tests/WalletConnectSignTests/WCPairingTests.swift +++ b/Tests/WalletConnectSignTests/WCPairingTests.swift @@ -1,5 +1,5 @@ import XCTest -import WalletConnectPairing +@testable import WalletConnectPairing @testable import WalletConnectSign final class WCPairingTests: XCTestCase { @@ -35,18 +35,67 @@ final class WCPairingTests: XCTestCase { XCTAssertEqual(pairing.expiryDate, inactiveExpiry) } - func testUpdateExpiry() { + func testUpdateExpiryForTopic() { var pairing = WCPairing(topic: "") let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) try? pairing.updateExpiry() XCTAssertEqual(pairing.expiryDate, activeExpiry) } + + func testUpdateExpiryForUri() { + var pairing = WCPairing(uri: WalletConnectURI.stub()) + let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) + try? pairing.updateExpiry() + XCTAssertEqual(pairing.expiryDate, activeExpiry) + } - func testActivate() { + func testActivateTopic() { var pairing = WCPairing(topic: "") let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) + XCTAssertFalse(pairing.active) + pairing.activate() + XCTAssertTrue(pairing.active) + XCTAssertEqual(pairing.expiryDate, activeExpiry) + } + + func testActivateURI() { + var pairing = WCPairing(uri: WalletConnectURI.stub()) + let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) + XCTAssertFalse(pairing.active) pairing.activate() XCTAssertTrue(pairing.active) XCTAssertEqual(pairing.expiryDate, activeExpiry) } + + func testUpdateExpiryWhenValueIsGreaterThanMax() { + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: referenceDate) + XCTAssertThrowsError(try pairing.updateExpiry(40 * .day)) { error in + XCTAssertEqual(error as! WCPairing.Errors, WCPairing.Errors.invalidUpdateExpiryValue) + } + } + + func testUpdateExpiryWhenNewExpiryDateIsLessThanExpiryDate() { + let expiryDate = referenceDate.advanced(by: 40 * .day) + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: expiryDate) + XCTAssertThrowsError(try pairing.updateExpiry(10 * .minute)) { error in + XCTAssertEqual(error as! WCPairing.Errors, WCPairing.Errors.invalidUpdateExpiryValue) + } + } + + func testActivateWhenCanUpdateExpiry() { + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: referenceDate) + XCTAssertFalse(pairing.active) + pairing.activate() + XCTAssertTrue(pairing.active) + XCTAssertEqual(referenceDate.advanced(by: 30 * .day), pairing.expiryDate) + } + + func testActivateWhenUpdateExpiryIsInvalid() { + let expiryDate = referenceDate.advanced(by: 40 * .day) + var pairing = WCPairing(topic: "", relay: .stub(), peerMetadata: .stub(), expiryDate: expiryDate) + XCTAssertFalse(pairing.active) + pairing.activate() + XCTAssertTrue(pairing.active) + XCTAssertEqual(expiryDate, pairing.expiryDate) + } }