diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectSignTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectSignTests.xcscheme new file mode 100644 index 000000000..bb6f830b9 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectSignTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectUtilsTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectUtilsTests.xcscheme new file mode 100644 index 000000000..c78f495d4 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectUtilsTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/DApp/ApplicationLayer/Application.swift b/Example/DApp/ApplicationLayer/Application.swift index feba5e373..e4c9d7c63 100644 --- a/Example/DApp/ApplicationLayer/Application.swift +++ b/Example/DApp/ApplicationLayer/Application.swift @@ -4,5 +4,4 @@ import WalletConnectUtils final class Application { var uri: WalletConnectURI? - var requestSent = false } diff --git a/Example/DApp/Common/ActivityIndicatorManager.swift b/Example/DApp/Common/ActivityIndicatorManager.swift new file mode 100644 index 000000000..2405ea052 --- /dev/null +++ b/Example/DApp/Common/ActivityIndicatorManager.swift @@ -0,0 +1,42 @@ +import UIKit + +class ActivityIndicatorManager { + static let shared = ActivityIndicatorManager() + private var activityIndicator: UIActivityIndicatorView? + private let serialQueue = DispatchQueue(label: "com.yourapp.activityIndicatorManager") + + private init() {} + + func start() { + serialQueue.async { + self.stopInternal() + + DispatchQueue.main.async { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { return } + + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.center = window.center + activityIndicator.color = .white + activityIndicator.startAnimating() + window.addSubview(activityIndicator) + + self.activityIndicator = activityIndicator + } + } + } + + func stop() { + serialQueue.async { + self.stopInternal() + } + } + + private func stopInternal() { + DispatchQueue.main.sync { + self.activityIndicator?.stopAnimating() + self.activityIndicator?.removeFromSuperview() + self.activityIndicator = nil + } + } +} diff --git a/Example/DApp/Common/AlertPresenter.swift b/Example/DApp/Common/AlertPresenter.swift new file mode 100644 index 000000000..5da5d4668 --- /dev/null +++ b/Example/DApp/Common/AlertPresenter.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftMessages +import UIKit + +struct AlertPresenter { + enum MessageType { + case warning + case error + case info + case success + } + + static func present(message: String, type: AlertPresenter.MessageType) { + DispatchQueue.main.async { + let view = MessageView.viewFromNib(layout: .cardView) + switch type { + case .warning: + view.configureTheme(.warning, iconStyle: .subtle) + case .error: + view.configureTheme(.error, iconStyle: .subtle) + case .info: + view.configureTheme(.info, iconStyle: .subtle) + case .success: + view.configureTheme(.success, iconStyle: .subtle) + } + view.button?.isHidden = true + view.layoutMarginAdditions = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + view.configureContent(title: "", body: message) + var config = SwiftMessages.Config() + config.presentationStyle = .top + config.duration = .seconds(seconds: 1.5) + SwiftMessages.show(config: config, view: view) + } + } +} diff --git a/Example/DApp/Modules/Auth/AuthInteractor.swift b/Example/DApp/Modules/Auth/AuthInteractor.swift deleted file mode 100644 index ffddc61dd..000000000 --- a/Example/DApp/Modules/Auth/AuthInteractor.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation - -final class AuthInteractor {} diff --git a/Example/DApp/Modules/Auth/AuthModule.swift b/Example/DApp/Modules/Auth/AuthModule.swift deleted file mode 100644 index 9252f89e3..000000000 --- a/Example/DApp/Modules/Auth/AuthModule.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -final class AuthModule { - @discardableResult - static func create(app: Application) -> UIViewController { - let router = AuthRouter(app: app) - let interactor = AuthInteractor() - let presenter = AuthPresenter(interactor: interactor, router: router) - let view = AuthView().environmentObject(presenter) - let viewController = SceneViewController(viewModel: presenter, content: view) - - router.viewController = viewController - - return viewController - } -} diff --git a/Example/DApp/Modules/Auth/AuthPresenter.swift b/Example/DApp/Modules/Auth/AuthPresenter.swift deleted file mode 100644 index 8e01e32e6..000000000 --- a/Example/DApp/Modules/Auth/AuthPresenter.swift +++ /dev/null @@ -1,116 +0,0 @@ -import UIKit -import Combine - -import Auth - -final class AuthPresenter: ObservableObject { - enum SigningState { - case none - case signed(Cacao) - case error(Error) - } - - private let interactor: AuthInteractor - private let router: AuthRouter - - @Published var qrCodeImageData: Data? - @Published var signingState = SigningState.none - @Published var showSigningState = false - - private var walletConnectUri: WalletConnectURI? - - private var subscriptions = Set() - - init( - interactor: AuthInteractor, - router: AuthRouter - ) { - defer { - Task { - await setupInitialState() - } - } - self.interactor = interactor - self.router = router - } - - func onAppear() { - generateQR() - } - - func copyUri() { - UIPasteboard.general.string = walletConnectUri?.absoluteString - } - - func connectWallet() { - if let walletConnectUri { - let walletUri = URL(string: "walletapp://wc?uri=\(walletConnectUri.deeplinkUri.removingPercentEncoding!)")! - DispatchQueue.main.async { - UIApplication.shared.open(walletUri) - } - } - } -} - -// MARK: - Private functions -extension AuthPresenter { - @MainActor - private func setupInitialState() { - Auth.instance.authResponsePublisher.sink { [weak self] (_, result) in - switch result { - case .success(let cacao): - self?.signingState = .signed(cacao) - self?.generateQR() - self?.showSigningState.toggle() - - case .failure(let error): - self?.signingState = .error(error) - self?.showSigningState.toggle() - } - } - .store(in: &subscriptions) - } - - private func generateQR() { - Task { @MainActor in - let uri = try! await Pair.instance.create() - walletConnectUri = uri - try await Auth.instance.request(.stub(), topic: uri.topic) - let qrCodeImage = QRCodeGenerator.generateQRCode(from: uri.absoluteString) - DispatchQueue.main.async { - self.qrCodeImageData = qrCodeImage.pngData() - } - } - } -} - -// MARK: - SceneViewModel -extension AuthPresenter: SceneViewModel {} - -// MARK: - Auth request stub -private extension RequestParams { - static func stub( - domain: String = "service.invalid", - chainId: String = "eip155:1", - nonce: String = "32891756", - aud: String = "https://service.invalid/login", - nbf: String? = nil, - exp: String? = nil, - statement: String? = "I accept the ServiceOrg Terms of Service: https://service.invalid/tos", - requestId: String? = nil, - resources: [String]? = ["ipfs://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq/", "https://example.com/my-web2-claim.json"] - ) -> RequestParams { - return RequestParams( - domain: domain, - chainId: chainId, - nonce: nonce, - aud: aud, - nbf: nbf, - exp: exp, - statement: statement, - requestId: requestId, - resources: resources - ) - } -} - diff --git a/Example/DApp/Modules/Auth/AuthRouter.swift b/Example/DApp/Modules/Auth/AuthRouter.swift deleted file mode 100644 index 3caacfd38..000000000 --- a/Example/DApp/Modules/Auth/AuthRouter.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -final class AuthRouter { - weak var viewController: UIViewController! - - private let app: Application - - init(app: Application) { - self.app = app - } - - func dismiss() { - viewController.dismiss(animated: true) - UIApplication.shared.open(URL(string: "showcase://")!) - } -} diff --git a/Example/DApp/Modules/Auth/AuthView.swift b/Example/DApp/Modules/Auth/AuthView.swift deleted file mode 100644 index 8e15bacc0..000000000 --- a/Example/DApp/Modules/Auth/AuthView.swift +++ /dev/null @@ -1,140 +0,0 @@ -import SwiftUI - -struct AuthView: View { - @EnvironmentObject var presenter: AuthPresenter - - var body: some View { - NavigationStack { - ZStack { - Color(red: 25/255, green: 26/255, blue: 26/255) - .ignoresSafeArea() - - VStack { - ZStack { - RoundedRectangle(cornerRadius: 25) - .fill(.white) - .aspectRatio(1, contentMode: .fit) - .padding(20) - - if let data = presenter.qrCodeImageData { - let qrCodeImage = UIImage(data: data) ?? UIImage() - Image(uiImage: qrCodeImage) - .resizable() - .aspectRatio(1, contentMode: .fit) - .padding(40) - } - } - - Button { - presenter.connectWallet() - } label: { - Text("Connect Sample Wallet") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color(red: 95/255, green: 159/255, blue: 248/255)) - .cornerRadius(16) - } - - Button { - presenter.copyUri() - } label: { - HStack { - Image("copy") - Text("Copy link") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(Color(red: 0.58, green: 0.62, blue: 0.62)) - } - } - .padding(.top, 16) - - Spacer() - } - } - .navigationTitle("Auth") - .navigationBarTitleDisplayMode(.inline) - .toolbarColorScheme(.dark, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - .toolbarBackground( - Color(red: 25/255, green: 26/255, blue: 26/255), - for: .navigationBar - ) - .onAppear { - presenter.onAppear() - } - .sheet(isPresented: $presenter.showSigningState) { - ZStack { - Color(red: 25/255, green: 26/255, blue: 26/255) - .ignoresSafeArea() - - VStack { - HStack { - RoundedRectangle(cornerRadius: 2) - .fill(.gray.opacity(0.5)) - .frame(width: 30, height: 4) - - } - .padding(20) - - Image("profile") - .resizable() - .frame(width: 64, height: 64) - - switch presenter.signingState { - case .error(let error): - Text(error.localizedDescription) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.green) - .cornerRadius(16) - .padding(.top, 16) - - case .signed(let cacao): - HStack { - Text(cacao.p.iss.split(separator: ":").last ?? "") - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 135) - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(Color(red: 0.89, green: 0.91, blue: 0.91)) - - Button { - UIPasteboard.general.string = String(cacao.p.iss.split(separator: ":").last ?? "") - } label: { - Image("copy") - .resizable() - .frame(width: 14, height: 14) - } - } - - Text("Authenticated") - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.green) - .cornerRadius(16) - .padding(.top, 16) - - case .none: - EmptyView() - } - - Spacer() - } - } - .presentationDetents([.medium]) - } - } - } -} - -struct AuthView_Previews: PreviewProvider { - static var previews: some View { - AuthView() - } -} - diff --git a/Example/DApp/Modules/Main/MainInteractor.swift b/Example/DApp/Modules/Main/MainInteractor.swift deleted file mode 100644 index a3954796d..000000000 --- a/Example/DApp/Modules/Main/MainInteractor.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation - -final class MainInteractor {} diff --git a/Example/DApp/Modules/Main/MainModule.swift b/Example/DApp/Modules/Main/MainModule.swift deleted file mode 100644 index 67a9d6060..000000000 --- a/Example/DApp/Modules/Main/MainModule.swift +++ /dev/null @@ -1,15 +0,0 @@ -import SwiftUI - -final class MainModule { - @discardableResult - static func create(app: Application) -> UIViewController { - let router = MainRouter(app: app) - let interactor = MainInteractor() - let presenter = MainPresenter(router: router, interactor: interactor) - let viewController = MainViewController(presenter: presenter) - - router.viewController = viewController - - return viewController - } -} diff --git a/Example/DApp/Modules/Main/MainPresenter.swift b/Example/DApp/Modules/Main/MainPresenter.swift deleted file mode 100644 index c3c7d47ee..000000000 --- a/Example/DApp/Modules/Main/MainPresenter.swift +++ /dev/null @@ -1,32 +0,0 @@ -import UIKit -import Combine - -final class MainPresenter { - private let interactor: MainInteractor - private let router: MainRouter - private var disposeBag = Set() - - var tabs: [TabPage] { - return TabPage.allCases - } - - var viewControllers: [UIViewController] { - return [ - router.signViewController(), - router.authViewController() - ] - } - - init(router: MainRouter, interactor: MainInteractor) { - defer { - setupInitialState() - } - self.router = router - self.interactor = interactor - } -} - -// MARK: - Private functions -extension MainPresenter { - private func setupInitialState() {} -} diff --git a/Example/DApp/Modules/Main/MainRouter.swift b/Example/DApp/Modules/Main/MainRouter.swift deleted file mode 100644 index 4b3fef3de..000000000 --- a/Example/DApp/Modules/Main/MainRouter.swift +++ /dev/null @@ -1,19 +0,0 @@ -import UIKit - -final class MainRouter { - weak var viewController: UIViewController! - - private let app: Application - - init(app: Application) { - self.app = app - } - - func signViewController() -> UIViewController { - return SignModule.create(app: app) - } - - func authViewController() -> UIViewController { - return AuthModule.create(app: app) - } -} diff --git a/Example/DApp/Modules/Main/MainViewController.swift b/Example/DApp/Modules/Main/MainViewController.swift deleted file mode 100644 index 539b2a789..000000000 --- a/Example/DApp/Modules/Main/MainViewController.swift +++ /dev/null @@ -1,43 +0,0 @@ -import UIKit -import Sentry - -enum LoginError: Error { - case wrongUser(id: String) - case wrongPassword -} - -final class MainViewController: UITabBarController { - - private let presenter: MainPresenter - - init(presenter: MainPresenter) { - self.presenter = presenter - super.init(nibName: nil, bundle: nil) - } - - override func viewDidLoad() { - super.viewDidLoad() - setupTabs() - } - - private func setupTabs() { - let viewControllers = presenter.viewControllers - - for (index, viewController) in viewControllers.enumerated() { - let model = presenter.tabs[index] - let item = UITabBarItem() - item.title = model.title - item.image = model.icon - item.isEnabled = TabPage.enabledTabs.contains(model) - viewController.tabBarItem = item - viewController.view.backgroundColor = .w_background - } - - self.viewControllers = viewControllers - self.selectedIndex = TabPage.selectedIndex - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Example/DApp/Modules/Main/Model/TabPage.swift b/Example/DApp/Modules/Main/Model/TabPage.swift deleted file mode 100644 index faec2ba34..000000000 --- a/Example/DApp/Modules/Main/Model/TabPage.swift +++ /dev/null @@ -1,32 +0,0 @@ -import UIKit - -enum TabPage: CaseIterable { - case sign - case auth - - var title: String { - switch self { - case .sign: - return "Sign" - case .auth: - return "Auth" - } - } - - var icon: UIImage { - switch self { - case .sign: - return UIImage(named: "pen")! - case .auth: - return UIImage(named: "auth")! - } - } - - static var selectedIndex: Int { - return 0 - } - - static var enabledTabs: [TabPage] { - return [.sign, .auth] - } -} diff --git a/Example/DApp/Modules/Sign/NewPairing/NewPairingRouter.swift b/Example/DApp/Modules/Sign/NewPairing/NewPairingRouter.swift index 51fc5b2f2..89c0ea8a7 100644 --- a/Example/DApp/Modules/Sign/NewPairing/NewPairingRouter.swift +++ b/Example/DApp/Modules/Sign/NewPairing/NewPairingRouter.swift @@ -11,6 +11,5 @@ final class NewPairingRouter { func dismiss() { viewController.dismiss(animated: true) - UIApplication.shared.open(URL(string: "showcase://")!) } } diff --git a/Example/DApp/Modules/Sign/SessionAccount/SessionAccountPresenter.swift b/Example/DApp/Modules/Sign/SessionAccount/SessionAccountPresenter.swift index 15b0b7e74..31920ce46 100644 --- a/Example/DApp/Modules/Sign/SessionAccount/SessionAccountPresenter.swift +++ b/Example/DApp/Modules/Sign/SessionAccount/SessionAccountPresenter.swift @@ -12,7 +12,10 @@ final class SessionAccountPresenter: ObservableObject { @Published var showError = false @Published var errorMessage = String.empty @Published var showRequestSent = false - + @Published var requesting = false + var lastRequest: Request? + + private let interactor: SessionAccountInteractor private let router: SessionAccountRouter private let session: Session @@ -41,14 +44,21 @@ final class SessionAccountPresenter: ObservableObject { do { let requestParams = try getRequest(for: method) - let request = Request(topic: session.topic, method: method, params: requestParams, chainId: Blockchain(sessionAccount.chain)!) + let ttl: TimeInterval = 300 + let request = try Request(topic: session.topic, method: method, params: requestParams, chainId: Blockchain(sessionAccount.chain)!, ttl: ttl) Task { do { + ActivityIndicatorManager.shared.start() try await Sign.instance.request(params: request) + lastRequest = request + ActivityIndicatorManager.shared.stop() + requesting = true DispatchQueue.main.async { [weak self] in self?.openWallet() } } catch { + ActivityIndicatorManager.shared.stop() + requesting = false showError.toggle() errorMessage = error.localizedDescription } @@ -70,6 +80,7 @@ extension SessionAccountPresenter { Sign.instance.sessionResponsePublisher .receive(on: DispatchQueue.main) .sink { [unowned self] response in + requesting = false presentResponse(response: response) } .store(in: &subscriptions) diff --git a/Example/DApp/Modules/Sign/SessionAccount/SessionAccountView.swift b/Example/DApp/Modules/Sign/SessionAccount/SessionAccountView.swift index 1b8a3ebb8..939a9edb6 100644 --- a/Example/DApp/Modules/Sign/SessionAccount/SessionAccountView.swift +++ b/Example/DApp/Modules/Sign/SessionAccount/SessionAccountView.swift @@ -8,9 +8,11 @@ struct SessionAccountView: View { var body: some View { NavigationStack { ZStack { + Color(red: 25/255, green: 26/255, blue: 26/255) .ignoresSafeArea() - + + ScrollView { VStack(spacing: 12) { networkView(title: String(presenter.sessionAccount.chain.split(separator: ":").first ?? "")) @@ -21,6 +23,14 @@ struct SessionAccountView: View { } .padding(12) } + + if presenter.requesting { + loadingView + .frame(width: 200, height: 200) + .background(Color.gray.opacity(0.95)) + .cornerRadius(20) + .shadow(radius: 10) + } } .navigationTitle(presenter.sessionAccount.chain) .navigationBarTitleDisplayMode(.inline) @@ -179,7 +189,18 @@ struct SessionAccountView: View { } } } - + + private var loadingView: some View { + VStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + .scaleEffect(1.5) + Text("Request sent, waiting for response") + .foregroundColor(.white) + .padding(.top, 20) + } + } + private func responseView(response: Response) -> some View { ZStack { RoundedRectangle(cornerRadius: 16) @@ -209,14 +230,14 @@ struct SessionAccountView: View { .padding(12) Spacer() - - let record = Sign.instance.getSessionRequestRecord(id: response.id)! - Text(record.request.method) - .font( - Font.system(size: 14, weight: .medium) - ) - .foregroundColor(Color(red: 0.58, green: 0.62, blue: 0.62)) - .padding(12) + if let lastRequest = presenter.lastRequest { + Text(lastRequest.method) + .font( + Font.system(size: 14, weight: .medium) + ) + .foregroundColor(Color(red: 0.58, green: 0.62, blue: 0.62)) + .padding(12) + } } ZStack { diff --git a/Example/DApp/Modules/Sign/SignPresenter.swift b/Example/DApp/Modules/Sign/SignPresenter.swift index b42d2663c..1c5194e2c 100644 --- a/Example/DApp/Modules/Sign/SignPresenter.swift +++ b/Example/DApp/Modules/Sign/SignPresenter.swift @@ -57,12 +57,18 @@ final class SignPresenter: ObservableObject { Task { let uri = try await Pair.instance.create() walletConnectUri = uri - try await Sign.instance.connect( - requiredNamespaces: Proposal.requiredNamespaces, - optionalNamespaces: Proposal.optionalNamespaces, - topic: uri.topic - ) - router.presentNewPairing(walletConnectUri: uri) + do { + ActivityIndicatorManager.shared.start() + try await Sign.instance.connect( + requiredNamespaces: Proposal.requiredNamespaces, + optionalNamespaces: Proposal.optionalNamespaces, + topic: uri.topic + ) + ActivityIndicatorManager.shared.stop() + router.presentNewPairing(walletConnectUri: uri) + } catch { + ActivityIndicatorManager.shared.stop() + } } } @@ -70,9 +76,12 @@ final class SignPresenter: ObservableObject { if let session { Task { @MainActor in do { + ActivityIndicatorManager.shared.start() try await Sign.instance.disconnect(topic: session.topic) + ActivityIndicatorManager.shared.stop() accountsDetails.removeAll() } catch { + ActivityIndicatorManager.shared.stop() showError.toggle() errorMessage = error.localizedDescription } @@ -104,8 +113,26 @@ extension SignPresenter { .receive(on: DispatchQueue.main) .sink { [unowned self] _ in self.accountsDetails.removeAll() + router.popToRoot() + Task(priority: .high) { ActivityIndicatorManager.shared.stop() } + } + .store(in: &subscriptions) + + Sign.instance.sessionResponsePublisher + .receive(on: DispatchQueue.main) + .sink { response in + Task(priority: .high) { ActivityIndicatorManager.shared.stop() } } .store(in: &subscriptions) + + Sign.instance.requestExpirationPublisher + .receive(on: DispatchQueue.main) + .sink { _ in + Task(priority: .high) { ActivityIndicatorManager.shared.stop() } + AlertPresenter.present(message: "Session Request has expired", type: .warning) + } + .store(in: &subscriptions) + } private func getSession() { diff --git a/Example/DApp/Modules/Sign/SignRouter.swift b/Example/DApp/Modules/Sign/SignRouter.swift index 60da1928c..a68ad5f9a 100644 --- a/Example/DApp/Modules/Sign/SignRouter.swift +++ b/Example/DApp/Modules/Sign/SignRouter.swift @@ -20,14 +20,14 @@ final class SignRouter { func presentSessionAccount(sessionAccount: AccountDetails, session: Session) { SessionAccountModule.create(app: app, sessionAccount: sessionAccount, session: session) - .present(from: viewController) + .push(from: viewController) } - - func dismissNewPairing() { - newPairingViewController?.dismiss() - } - + func dismiss() { viewController.dismiss(animated: true) } + + func popToRoot() { + viewController.popToRoot() + } } diff --git a/Example/DApp/Modules/Sign/SignView.swift b/Example/DApp/Modules/Sign/SignView.swift index fc60a1754..54c9d62e7 100644 --- a/Example/DApp/Modules/Sign/SignView.swift +++ b/Example/DApp/Modules/Sign/SignView.swift @@ -59,6 +59,7 @@ struct SignView: View { .padding(12) } } + .padding(.bottom, presenter.accountsDetails.isEmpty ? 0 : 76) .onAppear { presenter.onAppear() } diff --git a/Example/DApp/SceneDelegate.swift b/Example/DApp/SceneDelegate.swift index b52f47555..489a1c2d0 100644 --- a/Example/DApp/SceneDelegate.swift +++ b/Example/DApp/SceneDelegate.swift @@ -4,9 +4,11 @@ import Web3Modal import Auth import WalletConnectRelay import WalletConnectNetworking +import Combine class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + private var publishers = Set() private let app = Application() @@ -30,7 +32,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { projectId: InputConfig.projectId, metadata: metadata ) - + + Sign.instance.logsPublisher.sink { log in + switch log { + case .error(let logMessage): + AlertPresenter.present(message: logMessage.message, type: .error) + default: return + } + }.store(in: &publishers) + + Sign.instance.socketConnectionStatusPublisher.sink { status in + switch status { + case .connected: + AlertPresenter.present(message: "Your web socket has connected", type: .success) + case .disconnected: + AlertPresenter.present(message: "Your web socket is disconnected", type: .warning) + } + }.store(in: &publishers) + setupWindow(scene: scene) } @@ -38,7 +57,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = (scene as? UIWindowScene) else { return } window = UIWindow(windowScene: windowScene) - let viewController = MainModule.create(app: app) + let viewController = SignModule.create(app: app) + .wrapToNavigationController() window?.rootViewController = viewController window?.makeKeyAndVisible() diff --git a/Example/ExampleApp.xcodeproj/project.pbxproj b/Example/ExampleApp.xcodeproj/project.pbxproj index a5ee276b5..24712ead6 100644 --- a/Example/ExampleApp.xcodeproj/project.pbxproj +++ b/Example/ExampleApp.xcodeproj/project.pbxproj @@ -32,6 +32,8 @@ 847BD1E8298A806800076C90 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BD1E3298A806800076C90 /* NotificationsView.swift */; }; 847BD1EB298A87AB00076C90 /* SubscriptionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BD1EA298A87AB00076C90 /* SubscriptionsViewModel.swift */; }; 847F08012A25DBFF00B2A5A4 /* XPlatformW3WTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847F08002A25DBFF00B2A5A4 /* XPlatformW3WTests.swift */; }; + 8486EDD12B4F2DC1008E53C3 /* AlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8486EDD02B4F2DC1008E53C3 /* AlertPresenter.swift */; }; + 8486EDD32B4F2EA6008E53C3 /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 8486EDD22B4F2EA6008E53C3 /* SwiftMessages */; }; 8487A9442A836C2A0003D5AF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8487A9432A836C2A0003D5AF /* Sentry */; }; 8487A9462A836C3F0003D5AF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8487A9452A836C3F0003D5AF /* Sentry */; }; 8487A9482A83AD680003D5AF /* LoggingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8487A9472A83AD680003D5AF /* LoggingService.swift */; }; @@ -40,6 +42,9 @@ 849D7A93292E2169006A2BD4 /* NotifyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849D7A92292E2169006A2BD4 /* NotifyTests.swift */; }; 84A6E3C32A386BBC008A0571 /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A6E3C22A386BBC008A0571 /* Publisher.swift */; }; 84AA01DB28CF0CD7005D48D8 /* XCTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA01DA28CF0CD7005D48D8 /* XCTest.swift */; }; + 84AEC24F2B4D1EE400E27A5B /* ActivityIndicatorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AEC24E2B4D1EE400E27A5B /* ActivityIndicatorManager.swift */; }; + 84AEC2512B4D42C100E27A5B /* AlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AEC2502B4D42C100E27A5B /* AlertPresenter.swift */; }; + 84AEC2542B4D43CD00E27A5B /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 84AEC2532B4D43CD00E27A5B /* SwiftMessages */; }; 84B8154E2991099000FAD54E /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B8154D2991099000FAD54E /* BuildConfiguration.swift */; }; 84B8155B2992A18D00FAD54E /* NotifyMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B8155A2992A18D00FAD54E /* NotifyMessageViewModel.swift */; }; 84CE641F27981DED00142511 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CE641E27981DED00142511 /* AppDelegate.swift */; }; @@ -47,6 +52,7 @@ 84CE642827981DF000142511 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 84CE642727981DF000142511 /* Assets.xcassets */; }; 84CE642B27981DF000142511 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84CE642927981DF000142511 /* LaunchScreen.storyboard */; }; 84CEC64628D89D6B00D081A8 /* PairingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CEC64528D89D6B00D081A8 /* PairingTests.swift */; }; + 84D093EB2B4EA6CB005B1925 /* ActivityIndicatorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D093EA2B4EA6CB005B1925 /* ActivityIndicatorManager.swift */; }; 84D2A66628A4F51E0088AE09 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D2A66528A4F51E0088AE09 /* AuthTests.swift */; }; 84DB38F32983CDAE00BFEE37 /* PushRegisterer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DB38F22983CDAE00BFEE37 /* PushRegisterer.swift */; }; 84DDB4ED28ABB663003D66ED /* WalletConnectAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 84DDB4EC28ABB663003D66ED /* WalletConnectAuth */; }; @@ -288,12 +294,7 @@ C5B2F7052970573D000DBA0E /* SolanaSwift in Frameworks */ = {isa = PBXBuildFile; productRef = C5B2F7042970573D000DBA0E /* SolanaSwift */; }; C5B2F71029705827000DBA0E /* EthereumTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F568C32795832A00D0A289 /* EthereumTransaction.swift */; }; C5B4C4C42AF11C8B00B4274A /* SignView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B4C4C32AF11C8B00B4274A /* SignView.swift */; }; - C5B4C4CF2AF12F1600B4274A /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B4C4CE2AF12F1600B4274A /* AuthView.swift */; }; C5BE01D12AF661D70064FC88 /* NewPairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01D02AF661D70064FC88 /* NewPairingView.swift */; }; - C5BE01D72AF691CD0064FC88 /* AuthModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01D62AF691CD0064FC88 /* AuthModule.swift */; }; - C5BE01D92AF691FE0064FC88 /* AuthPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01D82AF691FE0064FC88 /* AuthPresenter.swift */; }; - C5BE01DB2AF692060064FC88 /* AuthRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01DA2AF692060064FC88 /* AuthRouter.swift */; }; - C5BE01DD2AF692100064FC88 /* AuthInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01DC2AF692100064FC88 /* AuthInteractor.swift */; }; C5BE01DF2AF692D80064FC88 /* WalletConnectRouter in Frameworks */ = {isa = PBXBuildFile; productRef = C5BE01DE2AF692D80064FC88 /* WalletConnectRouter */; }; C5BE01E22AF693080064FC88 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01E12AF693080064FC88 /* Application.swift */; }; C5BE01E32AF696540064FC88 /* SceneViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C56EE264293F56D6004840D1 /* SceneViewController.swift */; }; @@ -309,12 +310,6 @@ C5BE02022AF774CB0064FC88 /* NewPairingInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01F62AF6CA2B0064FC88 /* NewPairingInteractor.swift */; }; C5BE02032AF774CB0064FC88 /* NewPairingPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE01F42AF6CA2B0064FC88 /* NewPairingPresenter.swift */; }; C5BE02042AF7764F0064FC88 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C56EE26C293F56D6004840D1 /* UIViewController.swift */; }; - C5BE020E2AF777AD0064FC88 /* MainRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE02082AF777AD0064FC88 /* MainRouter.swift */; }; - C5BE020F2AF777AD0064FC88 /* TabPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE020D2AF777AD0064FC88 /* TabPage.swift */; }; - C5BE02102AF777AD0064FC88 /* MainModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE02072AF777AD0064FC88 /* MainModule.swift */; }; - C5BE02112AF777AD0064FC88 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE020A2AF777AD0064FC88 /* MainViewController.swift */; }; - C5BE02122AF777AD0064FC88 /* MainPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE020B2AF777AD0064FC88 /* MainPresenter.swift */; }; - C5BE02132AF777AD0064FC88 /* MainInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE02092AF777AD0064FC88 /* MainInteractor.swift */; }; C5BE02142AF77A940064FC88 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C5F32A352954FE3C00A6476E /* Colors.xcassets */; }; C5BE021B2AF79B9A0064FC88 /* SessionAccountPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE02172AF79B950064FC88 /* SessionAccountPresenter.swift */; }; C5BE021C2AF79B9A0064FC88 /* SessionAccountRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5BE02162AF79B950064FC88 /* SessionAccountRouter.swift */; }; @@ -425,6 +420,7 @@ 847BD1E3298A806800076C90 /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; 847BD1EA298A87AB00076C90 /* SubscriptionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsViewModel.swift; sourceTree = ""; }; 847F08002A25DBFF00B2A5A4 /* XPlatformW3WTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPlatformW3WTests.swift; sourceTree = ""; }; + 8486EDD02B4F2DC1008E53C3 /* AlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresenter.swift; sourceTree = ""; }; 8487A92E2A7BD2F30003D5AF /* XPlatformProtocolTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = XPlatformProtocolTests.xctestplan; path = ../XPlatformProtocolTests.xctestplan; sourceTree = ""; }; 8487A9472A83AD680003D5AF /* LoggingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingService.swift; sourceTree = ""; }; 849A4F18298281E300E61ACE /* WalletAppRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WalletAppRelease.entitlements; sourceTree = ""; }; @@ -432,6 +428,8 @@ 849D7A92292E2169006A2BD4 /* NotifyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyTests.swift; sourceTree = ""; }; 84A6E3C22A386BBC008A0571 /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; 84AA01DA28CF0CD7005D48D8 /* XCTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTest.swift; sourceTree = ""; }; + 84AEC24E2B4D1EE400E27A5B /* ActivityIndicatorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorManager.swift; sourceTree = ""; }; + 84AEC2502B4D42C100E27A5B /* AlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresenter.swift; sourceTree = ""; }; 84B8154D2991099000FAD54E /* BuildConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; 84B8155A2992A18D00FAD54E /* NotifyMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyMessageViewModel.swift; sourceTree = ""; }; 84CE641C27981DED00142511 /* DApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -442,6 +440,7 @@ 84CE642C27981DF000142511 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84CE6453279FFE1100142511 /* Wallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Wallet.entitlements; sourceTree = ""; }; 84CEC64528D89D6B00D081A8 /* PairingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingTests.swift; sourceTree = ""; }; + 84D093EA2B4EA6CB005B1925 /* ActivityIndicatorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorManager.swift; sourceTree = ""; }; 84D2A66528A4F51E0088AE09 /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = ""; }; 84D72FC62B4692770057EAF3 /* DApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DApp.entitlements; sourceTree = ""; }; 84DB38F029828A7C00BFEE37 /* WalletApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WalletApp.entitlements; sourceTree = ""; }; @@ -646,12 +645,7 @@ C5B2F6F42970511B000DBA0E /* SessionRequestInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRequestInteractor.swift; sourceTree = ""; }; C5B2F6F52970511B000DBA0E /* SessionRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRequestView.swift; sourceTree = ""; }; C5B4C4C32AF11C8B00B4274A /* SignView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignView.swift; sourceTree = ""; }; - C5B4C4CE2AF12F1600B4274A /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; C5BE01D02AF661D70064FC88 /* NewPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPairingView.swift; sourceTree = ""; }; - C5BE01D62AF691CD0064FC88 /* AuthModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthModule.swift; sourceTree = ""; }; - C5BE01D82AF691FE0064FC88 /* AuthPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthPresenter.swift; sourceTree = ""; }; - C5BE01DA2AF692060064FC88 /* AuthRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRouter.swift; sourceTree = ""; }; - C5BE01DC2AF692100064FC88 /* AuthInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthInteractor.swift; sourceTree = ""; }; C5BE01E12AF693080064FC88 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; C5BE01ED2AF6C9DF0064FC88 /* SignPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignPresenter.swift; sourceTree = ""; }; C5BE01EE2AF6C9DF0064FC88 /* SignModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignModule.swift; sourceTree = ""; }; @@ -661,12 +655,6 @@ C5BE01F42AF6CA2B0064FC88 /* NewPairingPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPairingPresenter.swift; sourceTree = ""; }; C5BE01F52AF6CA2B0064FC88 /* NewPairingModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPairingModule.swift; sourceTree = ""; }; C5BE01F62AF6CA2B0064FC88 /* NewPairingInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewPairingInteractor.swift; sourceTree = ""; }; - C5BE02072AF777AD0064FC88 /* MainModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainModule.swift; sourceTree = ""; }; - C5BE02082AF777AD0064FC88 /* MainRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainRouter.swift; sourceTree = ""; }; - C5BE02092AF777AD0064FC88 /* MainInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainInteractor.swift; sourceTree = ""; }; - C5BE020A2AF777AD0064FC88 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; - C5BE020B2AF777AD0064FC88 /* MainPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainPresenter.swift; sourceTree = ""; }; - C5BE020D2AF777AD0064FC88 /* TabPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPage.swift; sourceTree = ""; }; C5BE02162AF79B950064FC88 /* SessionAccountRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAccountRouter.swift; sourceTree = ""; }; C5BE02172AF79B950064FC88 /* SessionAccountPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAccountPresenter.swift; sourceTree = ""; }; C5BE02182AF79B950064FC88 /* SessionAccountInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAccountInteractor.swift; sourceTree = ""; }; @@ -720,6 +708,7 @@ A54195A52934E83F0035AD19 /* Web3 in Frameworks */, 8487A9442A836C2A0003D5AF /* Sentry in Frameworks */, A5D85228286333E300DAF5C3 /* Starscream in Frameworks */, + 8486EDD32B4F2EA6008E53C3 /* SwiftMessages in Frameworks */, 84943C7B2A9BA206007EBAC2 /* Mixpanel in Frameworks */, A573C53929EC365000E3CBFD /* HDWalletKit in Frameworks */, A5BB7FA328B6A50400707FC6 /* WalletConnectAuth in Frameworks */, @@ -787,6 +776,7 @@ C55D349929630D440004314A /* Web3Wallet in Frameworks */, A5F1526F2ACDC46B00D745A6 /* Web3ModalUI in Frameworks */, C56EE255293F569A004840D1 /* Starscream in Frameworks */, + 84AEC2542B4D43CD00E27A5B /* SwiftMessages in Frameworks */, A5B6C0F52A6EAB2800927332 /* WalletConnectNotify in Frameworks */, C56EE27B293F56F8004840D1 /* WalletConnectAuth in Frameworks */, C54C24902AEB1B5600DA4BF6 /* WalletConnectRouter in Frameworks */, @@ -1410,6 +1400,8 @@ children = ( A51AC0D828E436A3001BACF9 /* InputConfig.swift */, A5BB7FAC28B6AA7D00707FC6 /* QRCodeGenerator.swift */, + 84D093EA2B4EA6CB005B1925 /* ActivityIndicatorManager.swift */, + 8486EDD02B4F2DC1008E53C3 /* AlertPresenter.swift */, ); path = Common; sourceTree = ""; @@ -1655,6 +1647,8 @@ C56EE2A1293F6B9E004840D1 /* Helpers */, C56EE262293F56D6004840D1 /* Extensions */, C56EE263293F56D6004840D1 /* VIPER */, + 84AEC24E2B4D1EE400E27A5B /* ActivityIndicatorManager.swift */, + 84AEC2502B4D42C100E27A5B /* AlertPresenter.swift */, ); path = Common; sourceTree = ""; @@ -1797,18 +1791,6 @@ path = Sign; sourceTree = ""; }; - C5B4C4CD2AF12F0B00B4274A /* Auth */ = { - isa = PBXGroup; - children = ( - C5BE01D62AF691CD0064FC88 /* AuthModule.swift */, - C5BE01D82AF691FE0064FC88 /* AuthPresenter.swift */, - C5BE01DA2AF692060064FC88 /* AuthRouter.swift */, - C5BE01DC2AF692100064FC88 /* AuthInteractor.swift */, - C5B4C4CE2AF12F1600B4274A /* AuthView.swift */, - ); - path = Auth; - sourceTree = ""; - }; C5BE01E02AF692F80064FC88 /* ApplicationLayer */ = { isa = PBXGroup; children = ( @@ -1829,27 +1811,6 @@ path = NewPairing; sourceTree = ""; }; - C5BE02062AF777AD0064FC88 /* Main */ = { - isa = PBXGroup; - children = ( - C5BE02072AF777AD0064FC88 /* MainModule.swift */, - C5BE020B2AF777AD0064FC88 /* MainPresenter.swift */, - C5BE02092AF777AD0064FC88 /* MainInteractor.swift */, - C5BE02082AF777AD0064FC88 /* MainRouter.swift */, - C5BE020A2AF777AD0064FC88 /* MainViewController.swift */, - C5BE020C2AF777AD0064FC88 /* Model */, - ); - path = Main; - sourceTree = ""; - }; - C5BE020C2AF777AD0064FC88 /* Model */ = { - isa = PBXGroup; - children = ( - C5BE020D2AF777AD0064FC88 /* TabPage.swift */, - ); - path = Model; - sourceTree = ""; - }; C5BE02152AF79B860064FC88 /* SessionAccount */ = { isa = PBXGroup; children = ( @@ -1865,9 +1826,7 @@ C5BE02202AF7DDE70064FC88 /* Modules */ = { isa = PBXGroup; children = ( - C5BE02062AF777AD0064FC88 /* Main */, C5B4C4C52AF12C2900B4274A /* Sign */, - C5B4C4CD2AF12F0B00B4274A /* Auth */, ); path = Modules; sourceTree = ""; @@ -2002,6 +1961,7 @@ 84943C7A2A9BA206007EBAC2 /* Mixpanel */, C5BE01DE2AF692D80064FC88 /* WalletConnectRouter */, C579FEB52AFA86CD008855EB /* Web3Modal */, + 8486EDD22B4F2EA6008E53C3 /* SwiftMessages */, ); productName = DApp; productReference = 84CE641C27981DED00142511 /* DApp.app */; @@ -2131,6 +2091,7 @@ A59D25ED2AB3672700D7EA3A /* AsyncButton */, A5F1526E2ACDC46B00D745A6 /* Web3ModalUI */, C54C248F2AEB1B5600DA4BF6 /* WalletConnectRouter */, + 84AEC2532B4D43CD00E27A5B /* SwiftMessages */, ); productName = ChatWallet; productReference = C56EE21B293F55ED004840D1 /* WalletApp.app */; @@ -2209,6 +2170,7 @@ 8487A9422A836C2A0003D5AF /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 84943C792A9BA206007EBAC2 /* XCRemoteSwiftPackageReference "mixpanel-swift" */, A5F1526D2ACDC46B00D745A6 /* XCRemoteSwiftPackageReference "web3modal-swift" */, + 84AEC2522B4D43CD00E27A5B /* XCRemoteSwiftPackageReference "SwiftMessages" */, ); productRefGroup = 764E1D3D26F8D3FC00A1FB15 /* Products */; projectDirPath = ""; @@ -2309,45 +2271,36 @@ buildActionMask = 2147483647; files = ( C5BE021C2AF79B9A0064FC88 /* SessionAccountRouter.swift in Sources */, - C5BE02102AF777AD0064FC88 /* MainModule.swift in Sources */, - C5BE01DD2AF692100064FC88 /* AuthInteractor.swift in Sources */, C5B4C4C42AF11C8B00B4274A /* SignView.swift in Sources */, C5BE02042AF7764F0064FC88 /* UIViewController.swift in Sources */, C5BE021E2AF79B9A0064FC88 /* SessionAccountView.swift in Sources */, + 84D093EB2B4EA6CB005B1925 /* ActivityIndicatorManager.swift in Sources */, C5BE02032AF774CB0064FC88 /* NewPairingPresenter.swift in Sources */, 84CE641F27981DED00142511 /* AppDelegate.swift in Sources */, C5BE01E32AF696540064FC88 /* SceneViewController.swift in Sources */, - C5BE020F2AF777AD0064FC88 /* TabPage.swift in Sources */, A5A8E47D293A1CFE00FEB97D /* DefaultSocketFactory.swift in Sources */, C5BE01E52AF697470064FC88 /* Color.swift in Sources */, - C5B4C4CF2AF12F1600B4274A /* AuthView.swift in Sources */, A5BB7FAD28B6AA7D00707FC6 /* QRCodeGenerator.swift in Sources */, A51AC0D928E436A3001BACF9 /* InputConfig.swift in Sources */, - C5BE02122AF777AD0064FC88 /* MainPresenter.swift in Sources */, C5BE01E22AF693080064FC88 /* Application.swift in Sources */, C5BE021B2AF79B9A0064FC88 /* SessionAccountPresenter.swift in Sources */, A5A8E47E293A1CFE00FEB97D /* DefaultSignerFactory.swift in Sources */, - C5BE020E2AF777AD0064FC88 /* MainRouter.swift in Sources */, A5A0843D29D2F624000B9B17 /* DefaultCryptoProvider.swift in Sources */, C5BE021F2AF79B9A0064FC88 /* SessionAccountModule.swift in Sources */, C5BE01E42AF697100064FC88 /* UIColor.swift in Sources */, - C5BE01DB2AF692060064FC88 /* AuthRouter.swift in Sources */, C5BE01E62AF697FA0064FC88 /* String.swift in Sources */, C5BE02012AF774CB0064FC88 /* NewPairingModule.swift in Sources */, C5BE02002AF774CB0064FC88 /* NewPairingRouter.swift in Sources */, C5BE01F82AF6CB270064FC88 /* SignInteractor.swift in Sources */, + 8486EDD12B4F2DC1008E53C3 /* AlertPresenter.swift in Sources */, C5BE01D12AF661D70064FC88 /* NewPairingView.swift in Sources */, 84CE642127981DED00142511 /* SceneDelegate.swift in Sources */, C5BE02022AF774CB0064FC88 /* NewPairingInteractor.swift in Sources */, - C5BE01D92AF691FE0064FC88 /* AuthPresenter.swift in Sources */, - C5BE02112AF777AD0064FC88 /* MainViewController.swift in Sources */, C5BE021D2AF79B9A0064FC88 /* SessionAccountInteractor.swift in Sources */, A51606F82A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */, C5BE01FB2AF6CB270064FC88 /* SignRouter.swift in Sources */, - C5BE01D72AF691CD0064FC88 /* AuthModule.swift in Sources */, C5BE01F72AF6CB250064FC88 /* SignModule.swift in Sources */, C5BE01FA2AF6CB270064FC88 /* SignPresenter.swift in Sources */, - C5BE02132AF777AD0064FC88 /* MainInteractor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2500,10 +2453,12 @@ A51811A02A52E83100A52B15 /* SettingsPresenter.swift in Sources */, 847BD1DA2989492500076C90 /* MainRouter.swift in Sources */, C5F32A2E2954814A00A6476E /* ConnectionDetailsRouter.swift in Sources */, + 84AEC24F2B4D1EE400E27A5B /* ActivityIndicatorManager.swift in Sources */, C55D3482295DD7140004314A /* AuthRequestInteractor.swift in Sources */, C55D34B12965FB750004314A /* SessionProposalInteractor.swift in Sources */, C56EE247293F566D004840D1 /* ScanModule.swift in Sources */, C56EE28D293F5757004840D1 /* AppearanceConfigurator.swift in Sources */, + 84AEC2512B4D42C100E27A5B /* AlertPresenter.swift in Sources */, 847BD1D82989492500076C90 /* MainModule.swift in Sources */, 847BD1E7298A806800076C90 /* NotificationsInteractor.swift in Sources */, C56EE241293F566D004840D1 /* WalletModule.swift in Sources */, @@ -3340,6 +3295,14 @@ kind = branch; }; }; + 84AEC2522B4D43CD00E27A5B /* XCRemoteSwiftPackageReference "SwiftMessages" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SwiftKickMobile/SwiftMessages"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 9.0.9; + }; + }; A5434021291E6A270068F706 /* XCRemoteSwiftPackageReference "solana-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/flypaper0/solana-swift"; @@ -3408,6 +3371,11 @@ isa = XCSwiftPackageProductDependency; productName = Web3Wallet; }; + 8486EDD22B4F2EA6008E53C3 /* SwiftMessages */ = { + isa = XCSwiftPackageProductDependency; + package = 84AEC2522B4D43CD00E27A5B /* XCRemoteSwiftPackageReference "SwiftMessages" */; + productName = SwiftMessages; + }; 8487A9432A836C2A0003D5AF /* Sentry */ = { isa = XCSwiftPackageProductDependency; package = 8487A9422A836C2A0003D5AF /* XCRemoteSwiftPackageReference "sentry-cocoa" */; @@ -3428,6 +3396,11 @@ package = 84943C792A9BA206007EBAC2 /* XCRemoteSwiftPackageReference "mixpanel-swift" */; productName = Mixpanel; }; + 84AEC2532B4D43CD00E27A5B /* SwiftMessages */ = { + isa = XCSwiftPackageProductDependency; + package = 84AEC2522B4D43CD00E27A5B /* XCRemoteSwiftPackageReference "SwiftMessages" */; + productName = SwiftMessages; + }; 84DDB4EC28ABB663003D66ED /* WalletConnectAuth */ = { isa = XCSwiftPackageProductDependency; productName = WalletConnectAuth; diff --git a/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f11eb5be8..c46e295f2 100644 --- a/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -127,6 +127,15 @@ "version": "1.1.6" } }, + { + "package": "SwiftMessages", + "repositoryURL": "https://github.com/SwiftKickMobile/SwiftMessages", + "state": { + "branch": null, + "revision": "62e12e138fc3eedf88c7553dd5d98712aa119f40", + "version": "9.0.9" + } + }, { "package": "swiftui-async-button", "repositoryURL": "https://github.com/lorenzofiamingo/swiftui-async-button", @@ -168,8 +177,8 @@ "repositoryURL": "https://github.com/WalletConnect/web3modal-swift", "state": { "branch": null, - "revision": "3295d69d1b12df29a5040578d107f56986b1b399", - "version": "1.0.13" + "revision": "e84a07662d71721de4d0ccb2d3bb28fd993dd108", + "version": "1.0.14" } } ] diff --git a/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectPairing.xcscheme b/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectPairing.xcscheme index dc1c7488b..f4bd38aa8 100644 --- a/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectPairing.xcscheme +++ b/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectPairing.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + () - func makeClientDependencies(prefix: String) -> (PairingClient, NetworkInteracting, KeychainStorageProtocol, KeyValueStorage) { + func makeClientDependencies(prefix: String) -> (NetworkInteracting, KeychainStorageProtocol, KeyValueStorage) { let keychain = KeychainStorageMock() let keyValueStorage = RuntimeKeyValueStorage() let relayLogger = ConsoleLogger(prefix: prefix + " [Relay]", loggingLevel: .debug) - let pairingLogger = ConsoleLogger(prefix: prefix + " [Pairing]", loggingLevel: .debug) let networkingLogger = ConsoleLogger(prefix: prefix + " [Networking]", loggingLevel: .debug) let kmsLogger = ConsoleLogger(prefix: prefix + " [KMS]", loggingLevel: .debug) @@ -55,19 +53,13 @@ final class NotifyTests: XCTestCase { keyValueStorage: keyValueStorage, kmsLogger: kmsLogger) - let pairingClient = PairingClientFactory.create( - logger: pairingLogger, - keyValueStorage: keyValueStorage, - keychainStorage: keychain, - networkingClient: networkingClient) - let clientId = try! networkingClient.getClientId() networkingLogger.debug("My client id is: \(clientId)") - return (pairingClient, networkingClient, keychain, keyValueStorage) + return (networkingClient, keychain, keyValueStorage) } func makeWalletClient(prefix: String = "🦋 Wallet: ") -> NotifyClient { - let (pairingClient, networkingInteractor, keychain, keyValueStorage) = makeClientDependencies(prefix: prefix) + let (networkingInteractor, keychain, keyValueStorage) = makeClientDependencies(prefix: prefix) let notifyLogger = ConsoleLogger(prefix: prefix + " [Notify]", loggingLevel: .debug) let pushClient = PushClientFactory.create(projectId: "", pushHost: "echo.walletconnect.com", @@ -84,7 +76,6 @@ final class NotifyTests: XCTestCase { keychainStorage: keychain, groupKeychainStorage: KeychainStorageMock(), networkInteractor: networkingInteractor, - pairingRegisterer: pairingClient, pushClient: pushClient, crypto: DefaultCryptoProvider(), notifyHost: InputConfig.notifyHost, diff --git a/Example/IntegrationTests/Sign/SignClientTests.swift b/Example/IntegrationTests/Sign/SignClientTests.swift index 9e0fc6867..b1cfb2978 100644 --- a/Example/IntegrationTests/Sign/SignClientTests.swift +++ b/Example/IntegrationTests/Sign/SignClientTests.swift @@ -92,7 +92,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) } func testSessionReject() async throws { @@ -121,7 +121,7 @@ final class SignClientTests: XCTestCase { XCTAssertEqual(store.rejectedProposal, proposal) sessionRejectExpectation.fulfill() // TODO: Assert reason code }.store(in: &publishers) - wait(for: [sessionRejectExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [sessionRejectExpectation], timeout: InputConfig.defaultTimeout) } func testSessionDelete() async throws { @@ -146,7 +146,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [sessionDeleteExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [sessionDeleteExpectation], timeout: InputConfig.defaultTimeout) } func testSessionPing() async throws { @@ -177,7 +177,7 @@ final class SignClientTests: XCTestCase { try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) } func testSessionRequest() async throws { @@ -201,7 +201,7 @@ final class SignClientTests: XCTestCase { }.store(in: &publishers) dapp.sessionSettlePublisher.sink { [unowned self] settledSession in Task(priority: .high) { - let request = Request(id: RPCID(0), topic: settledSession.topic, method: requestMethod, params: requestParams, chainId: chain, expiry: nil) + let request = try! Request(id: RPCID(0), topic: settledSession.topic, method: requestMethod, params: requestParams, chainId: chain) try await dapp.request(params: request) } }.store(in: &publishers) @@ -227,7 +227,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [requestExpectation, responseExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [requestExpectation, responseExpectation], timeout: InputConfig.defaultTimeout) } func testSessionRequestFailureResponse() async throws { @@ -248,7 +248,7 @@ final class SignClientTests: XCTestCase { }.store(in: &publishers) dapp.sessionSettlePublisher.sink { [unowned self] settledSession in Task(priority: .high) { - let request = Request(id: RPCID(0), topic: settledSession.topic, method: requestMethod, params: requestParams, chainId: chain, expiry: nil) + let request = try! Request(id: RPCID(0), topic: settledSession.topic, method: requestMethod, params: requestParams, chainId: chain) try await dapp.request(params: request) } }.store(in: &publishers) @@ -270,7 +270,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) } func testNewSessionOnExistingPairing() async throws { @@ -308,7 +308,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) } func testSuccessfulSessionUpdateNamespaces() async throws { @@ -332,7 +332,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) } func testSuccessfulSessionExtend() async throws { @@ -361,7 +361,7 @@ final class SignClientTests: XCTestCase { try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) } func testSessionEventSucceeds() async throws { @@ -392,7 +392,7 @@ final class SignClientTests: XCTestCase { try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) } func testSessionEventFails() async throws { @@ -420,7 +420,7 @@ final class SignClientTests: XCTestCase { try await dapp.connect(requiredNamespaces: requiredNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [expectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [expectation], timeout: InputConfig.defaultTimeout) } func testCaip25SatisfyAllRequiredAllOptionalNamespacesSuccessful() async throws { @@ -498,7 +498,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, optionalNamespaces: optionalNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) } func testCaip25SatisfyAllRequiredNamespacesSuccessful() async throws { @@ -567,7 +567,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, optionalNamespaces: optionalNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) } func testCaip25SatisfyEmptyRequiredNamespacesExtraOptionalNamespacesSuccessful() async throws { @@ -626,7 +626,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, optionalNamespaces: optionalNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) + await fulfillment(of: [dappSettlementExpectation, walletSettlementExpectation], timeout: InputConfig.defaultTimeout) } func testCaip25SatisfyPartiallyRequiredNamespacesFails() async throws { @@ -689,7 +689,7 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, optionalNamespaces: optionalNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [settlementFailedExpectation], timeout: 1) + await fulfillment(of: [settlementFailedExpectation], timeout: InputConfig.defaultTimeout) } func testCaip25SatisfyPartiallyRequiredNamespacesMethodsFails() async throws { @@ -755,6 +755,6 @@ final class SignClientTests: XCTestCase { let uri = try! await dappPairingClient.create() try await dapp.connect(requiredNamespaces: requiredNamespaces, optionalNamespaces: optionalNamespaces, topic: uri.topic) try await walletPairingClient.pair(uri: uri) - wait(for: [settlementFailedExpectation], timeout: 1) + await fulfillment(of: [settlementFailedExpectation], timeout: 1) } } diff --git a/Example/WalletApp/ApplicationLayer/ConfigurationService.swift b/Example/WalletApp/ApplicationLayer/ConfigurationService.swift index 1267374f7..51d1f0c28 100644 --- a/Example/WalletApp/ApplicationLayer/ConfigurationService.swift +++ b/Example/WalletApp/ApplicationLayer/ConfigurationService.swift @@ -2,9 +2,12 @@ import UIKit import WalletConnectNetworking import WalletConnectNotify import Web3Wallet +import Combine final class ConfigurationService { + private var publishers = Set() + func configure(importAccount: ImportAccount) { Networking.configure( groupIdentifier: "group.com.walletconnect.sdk", @@ -38,6 +41,42 @@ final class ConfigurationService { } LoggingService.instance.startLogging() + Web3Wallet.instance.socketConnectionStatusPublisher + .receive(on: DispatchQueue.main) + .sink { status in + switch status { + case .connected: + AlertPresenter.present(message: "Your web socket has connected", type: .success) + case .disconnected: + AlertPresenter.present(message: "Your web socket is disconnected", type: .warning) + } + }.store(in: &publishers) + + Web3Wallet.instance.logsPublisher + .receive(on: DispatchQueue.main) + .sink { log in + switch log { + case .error(let logMessage): + AlertPresenter.present(message: logMessage.message, type: .error) + default: return + } + }.store(in: &publishers) + + Web3Wallet.instance.pairingExpirationPublisher + .receive(on: DispatchQueue.main) + .sink { pairing in + guard !pairing.active else { return } + AlertPresenter.present(message: "Pairing has expired", type: .warning) + }.store(in: &publishers) + + Web3Wallet.instance.sessionProposalExpirationPublisher.sink { _ in + AlertPresenter.present(message: "Session Proposal has expired", type: .warning) + }.store(in: &publishers) + + Web3Wallet.instance.requestExpirationPublisher.sink { _ in + AlertPresenter.present(message: "Session Request has expired", type: .warning) + }.store(in: &publishers) + Task { do { let params = try await Notify.instance.prepareRegistration(account: importAccount.account, domain: "com.walletconnect") diff --git a/Example/WalletApp/ApplicationLayer/SceneDelegate.swift b/Example/WalletApp/ApplicationLayer/SceneDelegate.swift index c36e6b4f6..ae9208eff 100644 --- a/Example/WalletApp/ApplicationLayer/SceneDelegate.swift +++ b/Example/WalletApp/ApplicationLayer/SceneDelegate.swift @@ -1,7 +1,7 @@ import Auth import SafariServices import UIKit -import WalletConnectPairing +import Web3Wallet final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UNUserNotificationCenterDelegate { var window: UIWindow? @@ -29,7 +29,13 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UNUserNotificatio window = UIWindow(windowScene: windowScene) window?.makeKeyAndVisible() - app.uri = WalletConnectURI(connectionOptions: connectionOptions) + do { + let uri = try WalletConnectURI(connectionOptions: connectionOptions) + app.uri = uri + } catch { + print("Error initializing WalletConnectURI: \(error.localizedDescription)") + } + app.requestSent = (connectionOptions.urlContexts.first?.url.absoluteString.replacingOccurrences(of: "walletapp://wc?", with: "") == "requestSent") configurators.configure() @@ -37,18 +43,25 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UNUserNotificatio UNUserNotificationCenter.current().delegate = self } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { guard let context = URLContexts.first else { return } - let uri = WalletConnectURI(urlContext: context) - - if let uri { + do { + let uri = try WalletConnectURI(urlContext: context) Task { - try await Pair.instance.pair(uri: uri) + try await Web3Wallet.instance.pair(uri: uri) + } + } catch { + if case WalletConnectURI.Errors.expired = error { + AlertPresenter.present(message: error.localizedDescription, type: .error) + } else { + print("Error initializing WalletConnectURI: \(error.localizedDescription)") } } } + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { open(notification: notification) return [.sound, .banner, .badge] diff --git a/Example/WalletApp/Common/ActivityIndicatorManager.swift b/Example/WalletApp/Common/ActivityIndicatorManager.swift new file mode 100644 index 000000000..9022a6f41 --- /dev/null +++ b/Example/WalletApp/Common/ActivityIndicatorManager.swift @@ -0,0 +1,42 @@ +import UIKit + +class ActivityIndicatorManager { + static let shared = ActivityIndicatorManager() + private var activityIndicator: UIActivityIndicatorView? + private let serialQueue = DispatchQueue(label: "com.yourapp.activityIndicatorManager") + + private init() {} + + func start() { + serialQueue.async { + self.stopInternal() + + DispatchQueue.main.async { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { return } + + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.center = window.center + activityIndicator.color = .blue + activityIndicator.startAnimating() + window.addSubview(activityIndicator) + + self.activityIndicator = activityIndicator + } + } + } + + func stop() { + serialQueue.async { + self.stopInternal() + } + } + + private func stopInternal() { + DispatchQueue.main.sync { + self.activityIndicator?.stopAnimating() + self.activityIndicator?.removeFromSuperview() + self.activityIndicator = nil + } + } +} diff --git a/Example/WalletApp/Common/AlertPresenter.swift b/Example/WalletApp/Common/AlertPresenter.swift new file mode 100644 index 000000000..5da5d4668 --- /dev/null +++ b/Example/WalletApp/Common/AlertPresenter.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftMessages +import UIKit + +struct AlertPresenter { + enum MessageType { + case warning + case error + case info + case success + } + + static func present(message: String, type: AlertPresenter.MessageType) { + DispatchQueue.main.async { + let view = MessageView.viewFromNib(layout: .cardView) + switch type { + case .warning: + view.configureTheme(.warning, iconStyle: .subtle) + case .error: + view.configureTheme(.error, iconStyle: .subtle) + case .info: + view.configureTheme(.info, iconStyle: .subtle) + case .success: + view.configureTheme(.success, iconStyle: .subtle) + } + view.button?.isHidden = true + view.layoutMarginAdditions = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + view.configureContent(title: "", body: message) + var config = SwiftMessages.Config() + config.presentationStyle = .top + config.duration = .seconds(seconds: 1.5) + SwiftMessages.show(config: config, view: view) + } + } +} diff --git a/Example/WalletApp/PresentationLayer/Wallet/AuthRequest/AuthRequestRouter.swift b/Example/WalletApp/PresentationLayer/Wallet/AuthRequest/AuthRequestRouter.swift index 76e35da11..d21c62d49 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/AuthRequest/AuthRequestRouter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/AuthRequest/AuthRequestRouter.swift @@ -11,6 +11,5 @@ final class AuthRequestRouter { func dismiss() { viewController.dismiss() - UIApplication.shared.open(URL(string: "showcase://")!) } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/ConnectionDetails/ConnectionDetailsPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/ConnectionDetails/ConnectionDetailsPresenter.swift index 5312123a4..8ba46dc19 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/ConnectionDetails/ConnectionDetailsPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/ConnectionDetails/ConnectionDetailsPresenter.swift @@ -25,11 +25,14 @@ final class ConnectionDetailsPresenter: ObservableObject { func onDelete() { Task { do { + ActivityIndicatorManager.shared.start() try await interactor.disconnectSession(session: session) + ActivityIndicatorManager.shared.stop() DispatchQueue.main.async { self.router.dismiss() } } catch { + ActivityIndicatorManager.shared.stop() print(error) } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift index 762bdf97c..3d5c8a0b6 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Main/MainPresenter.swift @@ -49,6 +49,7 @@ extension MainPresenter { interactor.sessionRequestPublisher .receive(on: DispatchQueue.main) .sink { [unowned self] request, context in + router.dismiss() router.present(sessionRequest: request, importAccount: importAccount, sessionContext: context) }.store(in: &disposeBag) diff --git a/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift b/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift index 211a1fc62..2f087957f 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Main/MainRouter.swift @@ -41,4 +41,8 @@ final class MainRouter { AuthRequestModule.create(app: app, request: request, importAccount: importAccount, context: context) .presentFullScreen(from: viewController, transparentBackground: true) } + + func dismiss() { + viewController.dismiss() + } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalPresenter.swift index 523158eee..e494039a2 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalPresenter.swift @@ -35,9 +35,12 @@ final class SessionProposalPresenter: ObservableObject { @MainActor func onApprove() async throws { do { + ActivityIndicatorManager.shared.start() let showConnected = try await interactor.approve(proposal: sessionProposal, account: importAccount.account) showConnected ? showConnectedSheet.toggle() : router.dismiss() + ActivityIndicatorManager.shared.stop() } catch { + ActivityIndicatorManager.shared.stop() errorMessage = error.localizedDescription showError.toggle() } @@ -46,9 +49,12 @@ final class SessionProposalPresenter: ObservableObject { @MainActor func onReject() async throws { do { + ActivityIndicatorManager.shared.start() try await interactor.reject(proposal: sessionProposal) + ActivityIndicatorManager.shared.stop() router.dismiss() } catch { + ActivityIndicatorManager.shared.stop() errorMessage = error.localizedDescription showError.toggle() } @@ -57,12 +63,31 @@ final class SessionProposalPresenter: ObservableObject { func onConnectedSheetDismiss() { router.dismiss() } + + func dismiss() { + router.dismiss() + } } // MARK: - Private functions private extension SessionProposalPresenter { func setupInitialState() { + Web3Wallet.instance.sessionProposalExpirationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] proposal in + guard let self = self else { return } + if proposal.id == self.sessionProposal.id { + dismiss() + } + }.store(in: &disposeBag) + Web3Wallet.instance.pairingExpirationPublisher + .receive(on: DispatchQueue.main) + .sink {[weak self] pairing in + if self?.sessionProposal.pairingTopic == pairing.topic { + self?.dismiss() + } + }.store(in: &disposeBag) } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalRouter.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalRouter.swift index cd9de7971..f03cce6db 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalRouter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalRouter.swift @@ -10,6 +10,8 @@ final class SessionProposalRouter { } func dismiss() { - viewController.dismiss() + DispatchQueue.main.async { [weak self] in + self?.viewController?.dismiss() + } } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalView.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalView.swift index 19ee52d1e..02f4b0401 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalView.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionProposal/SessionProposalView.swift @@ -13,6 +13,19 @@ struct SessionProposalView: View { VStack { Spacer() + VStack { + HStack { + Spacer() + Button(action: { + presenter.dismiss() + }) { + Image(systemName: "xmark") + .foregroundColor(.white) + .padding() + } + } + .padding() + } VStack(spacing: 0) { Image("header") .resizable() diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestInteractor.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestInteractor.swift index 59886fbfb..edc2ed4df 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestInteractor.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestInteractor.swift @@ -4,7 +4,7 @@ import Web3Wallet import WalletConnectRouter final class SessionRequestInteractor { - func approve(sessionRequest: Request, importAccount: ImportAccount) async throws -> Bool { + func respondSessionRequest(sessionRequest: Request, importAccount: ImportAccount) async throws -> Bool { do { let result = try Signer.sign(request: sessionRequest, importAccount: importAccount) try await Web3Wallet.instance.respond( @@ -12,7 +12,6 @@ final class SessionRequestInteractor { requestId: sessionRequest.id, response: .response(result) ) - /* Redirect */ let session = getSession(topic: sessionRequest.topic) if let uri = session?.peer.redirect?.native { @@ -26,7 +25,7 @@ final class SessionRequestInteractor { } } - func reject(sessionRequest: Request) async throws { + func respondError(sessionRequest: Request) async throws { try await Web3Wallet.instance.respond( topic: sessionRequest.topic, requestId: sessionRequest.id, diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestPresenter.swift index d8c00b710..17d2f9b49 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestPresenter.swift @@ -43,9 +43,12 @@ final class SessionRequestPresenter: ObservableObject { @MainActor func onApprove() async throws { do { - let showConnected = try await interactor.approve(sessionRequest: sessionRequest, importAccount: importAccount) + ActivityIndicatorManager.shared.start() + let showConnected = try await interactor.respondSessionRequest(sessionRequest: sessionRequest, importAccount: importAccount) showConnected ? showSignedSheet.toggle() : router.dismiss() + ActivityIndicatorManager.shared.stop() } catch { + ActivityIndicatorManager.shared.stop() errorMessage = error.localizedDescription showError.toggle() } @@ -53,8 +56,16 @@ final class SessionRequestPresenter: ObservableObject { @MainActor func onReject() async throws { - try await interactor.reject(sessionRequest: sessionRequest) - router.dismiss() + do { + ActivityIndicatorManager.shared.start() + try await interactor.respondError(sessionRequest: sessionRequest) + ActivityIndicatorManager.shared.stop() + router.dismiss() + } catch { + ActivityIndicatorManager.shared.stop() + errorMessage = error.localizedDescription + showError.toggle() + } } func onSignedSheetDismiss() { @@ -68,7 +79,16 @@ final class SessionRequestPresenter: ObservableObject { // MARK: - Private functions private extension SessionRequestPresenter { - func setupInitialState() {} + func setupInitialState() { + Web3Wallet.instance.requestExpirationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] request in + guard let self = self else { return } + if request.id == sessionRequest.id { + dismiss() + } + }.store(in: &disposeBag) + } } // MARK: - SceneViewModel diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestRouter.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestRouter.swift index cb7dff530..b4cbce9b5 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestRouter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestRouter.swift @@ -10,6 +10,8 @@ final class SessionRequestRouter { } func dismiss() { - viewController.dismiss() + DispatchQueue.main.async { [weak self] in + self?.viewController?.dismiss() + } } } diff --git a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestView.swift b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestView.swift index d64b1f82a..ebc9557b2 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestView.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/SessionRequest/SessionRequestView.swift @@ -11,7 +11,20 @@ struct SessionRequestView: View { VStack { Spacer() - + + VStack { + HStack { + Spacer() + Button(action: { + presenter.dismiss() + }) { + Image(systemName: "xmark") + .foregroundColor(.white) + .padding() + } + } + .padding() + } VStack(spacing: 0) { Image("header") .resizable() @@ -98,7 +111,7 @@ struct SessionRequestView: View { } .alert(presenter.errorMessage, isPresented: $presenter.showError) { Button("OK", role: .cancel) { - presenter.dismiss() +// presenter.dismiss() } } .sheet( diff --git a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift index 3a32be3f8..2b3c7ff4b 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletInteractor.swift @@ -19,11 +19,7 @@ final class WalletInteractor { func disconnectSession(session: Session) async throws { try await Web3Wallet.instance.disconnect(topic: session.topic) } - - func getPendingProposals() -> [(proposal: Session.Proposal, context: VerifyContext?)] { - Web3Wallet.instance.getPendingProposals() - } - + func getPendingRequests() -> [(request: Request, context: VerifyContext?)] { Web3Wallet.instance.getPendingRequests() } diff --git a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift index 59d5c1fc2..0affd430f 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletPresenter.swift @@ -13,10 +13,15 @@ final class WalletPresenter: ObservableObject { private let importAccount: ImportAccount private let app: Application - + private var isPairingTimer: Timer? + @Published var sessions = [Session]() - @Published var showPairingLoading = false + @Published var showPairingLoading = false { + didSet { + handlePairingLoadingChanged() + } + } @Published var showError = false @Published var errorMessage = "Error" @Published var showConnectedSheet = false @@ -40,7 +45,7 @@ final class WalletPresenter: ObservableObject { func onAppear() { showPairingLoading = app.requestSent - removePairingIndicator() + setUpPairingIndicatorRemoval() let pendingRequests = interactor.getPendingRequests() if let request = pendingRequests.first(where: { $0.context != nil }) { @@ -53,15 +58,15 @@ final class WalletPresenter: ObservableObject { } func onPasteUri() { - router.presentPaste { [weak self] uri in - guard let uri = WalletConnectURI(string: uri) else { - self?.errorMessage = Errors.invalidUri(uri: uri).localizedDescription + router.presentPaste { [weak self] uriString in + do { + let uri = try WalletConnectURI(uriString: uriString) + print("URI: \(uri)") + self?.pair(uri: uri) + } catch { + self?.errorMessage = error.localizedDescription self?.showError.toggle() - return } - print("URI: \(uri)") - self?.pair(uri: uri) - } onError: { [weak self] error in print(error.localizedDescription) self?.router.dismiss() @@ -69,24 +74,44 @@ final class WalletPresenter: ObservableObject { } func onScanUri() { - router.presentScan { [weak self] uri in - guard let uri = WalletConnectURI(string: uri) else { - self?.errorMessage = Errors.invalidUri(uri: uri).localizedDescription + router.presentScan { [weak self] uriString in + do { + let uri = try WalletConnectURI(uriString: uriString) + print("URI: \(uri)") + self?.pair(uri: uri) + self?.router.dismiss() + } catch { + self?.errorMessage = error.localizedDescription self?.showError.toggle() - return } - print("URI: \(uri)") - self?.pair(uri: uri) - self?.router.dismiss() - } onError: { error in + } onError: { [weak self] error in print(error.localizedDescription) - self.router.dismiss() + self?.router.dismiss() } } + func removeSession(at indexSet: IndexSet) async { if let index = indexSet.first { - try? await interactor.disconnectSession(session: sessions[index]) + do { + ActivityIndicatorManager.shared.start() + try await interactor.disconnectSession(session: sessions[index]) + ActivityIndicatorManager.shared.stop() + } catch { + ActivityIndicatorManager.shared.stop() + sessions = sessions + AlertPresenter.present(message: error.localizedDescription, type: .error) + } + } + } + + private func handlePairingLoadingChanged() { + isPairingTimer?.invalidate() + + if showPairingLoading { + isPairingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { _ in + AlertPresenter.present(message: "Pairing takes longer then expected, check your internet connection or try again", type: .warning) + } } } } @@ -109,11 +134,8 @@ extension WalletPresenter { private func pair(uri: WalletConnectURI) { Task.detached(priority: .high) { @MainActor [unowned self] in do { - self.showPairingLoading = true - self.removePairingIndicator() try await self.interactor.pair(uri: uri) } catch { - self.showPairingLoading = false self.errorMessage = error.localizedDescription self.showError.toggle() } @@ -126,11 +148,13 @@ extension WalletPresenter { } pair(uri: uri) } - - private func removePairingIndicator() { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.showPairingLoading = false - } + + private func setUpPairingIndicatorRemoval() { + Web3Wallet.instance.pairingStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isPairing in + self?.showPairingLoading = isPairing + }.store(in: &disposeBag) } } @@ -153,3 +177,4 @@ extension WalletPresenter.Errors: LocalizedError { } } } + diff --git a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletView.swift b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletView.swift index db735a7cf..e053493b4 100644 --- a/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletView.swift +++ b/Example/WalletApp/PresentationLayer/Wallet/Wallet/WalletView.swift @@ -35,7 +35,7 @@ struct WalletView: View { } .onDelete { indexSet in Task(priority: .high) { - await presenter.removeSession(at: indexSet) + try await presenter.removeSession(at: indexSet) } } } diff --git a/Package.swift b/Package.swift index 3321d7eb7..d6d573c09 100644 --- a/Package.swift +++ b/Package.swift @@ -73,7 +73,7 @@ let package = Package( path: "Sources/Web3Wallet"), .target( name: "WalletConnectNotify", - dependencies: ["WalletConnectIdentity", "WalletConnectPairing", "WalletConnectPush", "WalletConnectSigner", "Database"], + dependencies: ["WalletConnectIdentity", "WalletConnectPush", "WalletConnectSigner", "Database"], path: "Sources/WalletConnectNotify"), .target( name: "WalletConnectPush", diff --git a/Sources/WalletConnectNotify/Client/Wallet/NotifyClientFactory.swift b/Sources/WalletConnectNotify/Client/Wallet/NotifyClientFactory.swift index ec417a765..676f37b5a 100644 --- a/Sources/WalletConnectNotify/Client/Wallet/NotifyClientFactory.swift +++ b/Sources/WalletConnectNotify/Client/Wallet/NotifyClientFactory.swift @@ -2,7 +2,7 @@ import Foundation public struct NotifyClientFactory { - public static func create(projectId: String, groupIdentifier: String, networkInteractor: NetworkInteracting, pairingRegisterer: PairingRegisterer, pushClient: PushClient, crypto: CryptoProvider, notifyHost: String, explorerHost: String) -> NotifyClient { + public static func create(projectId: String, groupIdentifier: String, networkInteractor: NetworkInteracting, pushClient: PushClient, crypto: CryptoProvider, notifyHost: String, explorerHost: String) -> NotifyClient { let logger = ConsoleLogger(prefix: "🔔",loggingLevel: .debug) let keyserverURL = URL(string: "https://keys.walletconnect.com")! let keychainStorage = KeychainStorage(serviceIdentifier: "com.walletconnect.sdk", accessGroup: groupIdentifier) @@ -18,7 +18,6 @@ public struct NotifyClientFactory { keychainStorage: keychainStorage, groupKeychainStorage: groupKeychainService, networkInteractor: networkInteractor, - pairingRegisterer: pairingRegisterer, pushClient: pushClient, crypto: crypto, notifyHost: notifyHost, @@ -34,7 +33,6 @@ public struct NotifyClientFactory { keychainStorage: KeychainStorageProtocol, groupKeychainStorage: KeychainStorageProtocol, networkInteractor: NetworkInteracting, - pairingRegisterer: PairingRegisterer, pushClient: PushClient, crypto: CryptoProvider, notifyHost: String, diff --git a/Sources/WalletConnectNotify/Notify.swift b/Sources/WalletConnectNotify/Notify.swift index 9ed189307..3bb7b41f1 100644 --- a/Sources/WalletConnectNotify/Notify.swift +++ b/Sources/WalletConnectNotify/Notify.swift @@ -10,7 +10,6 @@ public class Notify { projectId: Networking.projectId, groupIdentifier: Networking.groupIdentifier, networkInteractor: Networking.interactor, - pairingRegisterer: Pair.registerer, pushClient: Push.instance, crypto: config.crypto, notifyHost: config.notifyHost, diff --git a/Sources/WalletConnectPairing/PairingClient.swift b/Sources/WalletConnectPairing/PairingClient.swift index 16d8f0076..5637ba3e6 100644 --- a/Sources/WalletConnectPairing/PairingClient.swift +++ b/Sources/WalletConnectPairing/PairingClient.swift @@ -9,7 +9,14 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient pairingDeleteRequestSubscriber.deletePublisherSubject.eraseToAnyPublisher() } + public var pairingStatePublisher: AnyPublisher { + return pairingStateProvider.pairingStatePublisher + } + public let socketConnectionStatusPublisher: AnyPublisher + public var pairingExpirationPublisher: AnyPublisher { + return expirationService.pairingExpirationPublisher + } private let pairingStorage: WCPairingStorage private let walletPairService: WalletPairService @@ -25,6 +32,7 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient private let resubscribeService: PairingResubscribeService private let expirationService: ExpirationService private let pairingDeleteRequestSubscriber: PairingDeleteRequestSubscriber + private let pairingStateProvider: PairingStateProvider private let cleanupService: PairingCleanupService @@ -47,7 +55,8 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient cleanupService: PairingCleanupService, pingService: PairingPingService, socketConnectionStatusPublisher: AnyPublisher, - pairingsProvider: PairingsProvider + pairingsProvider: PairingsProvider, + pairingStateProvider: PairingStateProvider ) { self.pairingStorage = pairingStorage self.appPairService = appPairService @@ -64,6 +73,7 @@ public class PairingClient: PairingRegisterer, PairingInteracting, PairingClient self.pingService = pingService self.pairingRequestsSubscriber = pairingRequestsSubscriber self.pairingsProvider = pairingsProvider + self.pairingStateProvider = pairingStateProvider setUpPublishers() setUpExpiration() } diff --git a/Sources/WalletConnectPairing/PairingClientFactory.swift b/Sources/WalletConnectPairing/PairingClientFactory.swift index 902ba7d28..22840bed3 100644 --- a/Sources/WalletConnectPairing/PairingClientFactory.swift +++ b/Sources/WalletConnectPairing/PairingClientFactory.swift @@ -36,6 +36,7 @@ public struct PairingClientFactory { let expirationService = ExpirationService(pairingStorage: pairingStore, networkInteractor: networkingClient, kms: kms) let resubscribeService = PairingResubscribeService(networkInteractor: networkingClient, pairingStorage: pairingStore) let pairingDeleteRequestSubscriber = PairingDeleteRequestSubscriber(networkingInteractor: networkingClient, kms: kms, pairingStorage: pairingStore, logger: logger) + let pairingStateProvider = PairingStateProvider(pairingStorage: pairingStore) return PairingClient( pairingStorage: pairingStore, @@ -52,7 +53,8 @@ public struct PairingClientFactory { cleanupService: cleanupService, pingService: pingService, socketConnectionStatusPublisher: networkingClient.socketConnectionStatusPublisher, - pairingsProvider: pairingsProvider + pairingsProvider: pairingsProvider, + pairingStateProvider: pairingStateProvider ) } } diff --git a/Sources/WalletConnectPairing/PairingClientProtocol.swift b/Sources/WalletConnectPairing/PairingClientProtocol.swift index 7edd05b30..905c1b4e8 100644 --- a/Sources/WalletConnectPairing/PairingClientProtocol.swift +++ b/Sources/WalletConnectPairing/PairingClientProtocol.swift @@ -3,6 +3,8 @@ import Combine public protocol PairingClientProtocol { var logsPublisher: AnyPublisher {get} var pairingDeletePublisher: AnyPublisher<(code: Int, message: String), Never> {get} + var pairingStatePublisher: AnyPublisher {get} + var pairingExpirationPublisher: AnyPublisher {get} func pair(uri: WalletConnectURI) async throws func disconnect(topic: String) async throws func getPairings() -> [Pairing] diff --git a/Sources/WalletConnectPairing/Services/App/AppPairService.swift b/Sources/WalletConnectPairing/Services/App/AppPairService.swift index 7dd5deb09..0ecb3a64e 100644 --- a/Sources/WalletConnectPairing/Services/App/AppPairService.swift +++ b/Sources/WalletConnectPairing/Services/App/AppPairService.swift @@ -15,8 +15,9 @@ actor AppPairService { let topic = String.generateTopic() try await networkingInteractor.subscribe(topic: topic) let symKey = try! kms.createSymmetricKey(topic) - let pairing = WCPairing(topic: topic) - let uri = WalletConnectURI(topic: topic, symKey: symKey.hexRepresentation, relay: pairing.relay) + let relay = RelayProtocolOptions(protocol: "irn", data: nil) + let uri = WalletConnectURI(topic: topic, symKey: symKey.hexRepresentation, relay: relay) + let pairing = WCPairing(uri: uri) pairingStorage.setPairing(pairing) return uri } diff --git a/Sources/WalletConnectPairing/Services/Common/ExpirationService.swift b/Sources/WalletConnectPairing/Services/Common/ExpirationService.swift index 4a8850cbe..0df4caf7e 100644 --- a/Sources/WalletConnectPairing/Services/Common/ExpirationService.swift +++ b/Sources/WalletConnectPairing/Services/Common/ExpirationService.swift @@ -1,9 +1,14 @@ import Foundation +import Combine final class ExpirationService { private let pairingStorage: WCPairingStorage private let networkInteractor: NetworkInteracting private let kms: KeyManagementServiceProtocol + private let pairingExpirationPublisherSubject: PassthroughSubject = .init() + var pairingExpirationPublisher: AnyPublisher { + pairingExpirationPublisherSubject.eraseToAnyPublisher() + } init(pairingStorage: WCPairingStorage, networkInteractor: NetworkInteracting, kms: KeyManagementServiceProtocol) { self.pairingStorage = pairingStorage @@ -15,6 +20,10 @@ final class ExpirationService { pairingStorage.onPairingExpiration = { [weak self] pairing in self?.kms.deleteSymmetricKey(for: pairing.topic) self?.networkInteractor.unsubscribe(topic: pairing.topic) + + DispatchQueue.main.async { + self?.pairingExpirationPublisherSubject.send(Pairing(pairing)) + } } } } diff --git a/Sources/WalletConnectPairing/Services/Common/PairingStateProvider.swift b/Sources/WalletConnectPairing/Services/Common/PairingStateProvider.swift new file mode 100644 index 000000000..ff917e33d --- /dev/null +++ b/Sources/WalletConnectPairing/Services/Common/PairingStateProvider.swift @@ -0,0 +1,33 @@ +import Combine +import Foundation + +class PairingStateProvider { + private let pairingStorage: WCPairingStorage + private var pairingStatePublisherSubject = PassthroughSubject() + private var checkTimer: Timer? + private var lastPairingState: Bool? + + public var pairingStatePublisher: AnyPublisher { + pairingStatePublisherSubject.eraseToAnyPublisher() + } + + public init(pairingStorage: WCPairingStorage) { + self.pairingStorage = pairingStorage + setupPairingStateCheckTimer() + } + + private func setupPairingStateCheckTimer() { + checkTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [unowned self] _ in + checkPairingState() + } + } + + private func checkPairingState() { + let pairingStateActive = !pairingStorage.getAll().allSatisfy { $0.active || $0.requestReceived } + + if lastPairingState != pairingStateActive { + pairingStatePublisherSubject.send(pairingStateActive) + lastPairingState = pairingStateActive + } + } +} diff --git a/Sources/WalletConnectPairing/Types/AppMetadata.swift b/Sources/WalletConnectPairing/Types/AppMetadata.swift index 4fcfee409..0ab317f12 100644 --- a/Sources/WalletConnectPairing/Types/AppMetadata.swift +++ b/Sources/WalletConnectPairing/Types/AppMetadata.swift @@ -70,3 +70,17 @@ public struct AppMetadata: Codable, Equatable { self.redirect = redirect } } + +#if DEBUG +public extension AppMetadata { + static func stub() -> AppMetadata { + AppMetadata( + name: "Wallet Connect", + description: "A protocol to connect blockchain wallets to dapps.", + url: "https://walletconnect.com/", + icons: [], + redirect: AppMetadata.Redirect(native: "", universal: nil) + ) + } +} +#endif diff --git a/Sources/WalletConnectPairing/Types/Pairing.swift b/Sources/WalletConnectPairing/Types/Pairing.swift index 03ed01a41..9edd37941 100644 --- a/Sources/WalletConnectPairing/Types/Pairing.swift +++ b/Sources/WalletConnectPairing/Types/Pairing.swift @@ -6,16 +6,20 @@ public struct Pairing { public let topic: String public let peer: AppMetadata? public let expiryDate: Date - - public init(topic: String, peer: AppMetadata?, expiryDate: Date) { - self.topic = topic - self.peer = peer - self.expiryDate = expiryDate - } + public let active: Bool init(_ pairing: WCPairing) { self.topic = pairing.topic self.peer = pairing.peerMetadata self.expiryDate = pairing.expiryDate + self.active = pairing.active + } +} + +#if DEBUG +extension Pairing { + static func stub(expiryDate: Date = Date(timeIntervalSinceNow: 10000), topic: String = String.generateTopic()) -> Pairing { + Pairing(WCPairing.stub(expiryDate: expiryDate, topic: topic)) } } +#endif diff --git a/Sources/WalletConnectPairing/Types/WCPairing.swift b/Sources/WalletConnectPairing/Types/WCPairing.swift index d87bd8946..a44ee01fa 100644 --- a/Sources/WalletConnectPairing/Types/WCPairing.swift +++ b/Sources/WalletConnectPairing/Types/WCPairing.swift @@ -27,29 +27,12 @@ public struct WCPairing: SequenceObject { 30 * .day } - public init(topic: String, relay: RelayProtocolOptions, peerMetadata: AppMetadata, isActive: Bool = false, requestReceived: Bool = false, expiryDate: Date) { - self.topic = topic - self.relay = relay - self.peerMetadata = peerMetadata - self.active = isActive - self.requestReceived = requestReceived - self.expiryDate = expiryDate - } - - public init(topic: String) { - self.topic = topic - self.relay = RelayProtocolOptions(protocol: "irn", data: nil) - self.active = false - self.requestReceived = false - self.expiryDate = Self.dateInitializer().advanced(by: Self.timeToLiveInactive) - } - public init(uri: WalletConnectURI) { self.topic = uri.topic self.relay = uri.relay self.active = false self.requestReceived = false - self.expiryDate = Self.dateInitializer().advanced(by: Self.timeToLiveInactive) + self.expiryDate = Date(timeIntervalSince1970: TimeInterval(uri.expiryTimestamp)) } public mutating func activate() { @@ -75,3 +58,31 @@ public struct WCPairing: SequenceObject { expiryDate = newExpiryDate } } + +#if DEBUG +extension WCPairing { + static func stub(expiryDate: Date = Date(timeIntervalSinceNow: 10000), isActive: Bool = true, topic: String = String.generateTopic()) -> WCPairing { + WCPairing(topic: topic, relay: RelayProtocolOptions.stub(), peerMetadata: AppMetadata.stub(), isActive: isActive, expiryDate: expiryDate) + } + + init(topic: String, relay: RelayProtocolOptions, peerMetadata: AppMetadata, isActive: Bool = false, requestReceived: Bool = false, expiryDate: Date) { + self.topic = topic + self.relay = relay + self.peerMetadata = peerMetadata + self.active = isActive + self.requestReceived = requestReceived + self.expiryDate = expiryDate + } +} + +extension WalletConnectURI { + public static func stub() -> WalletConnectURI { + WalletConnectURI( + topic: String.generateTopic(), + symKey: SymmetricKey().hexRepresentation, + relay: RelayProtocolOptions(protocol: "", data: nil) + ) + } +} + +#endif diff --git a/Sources/WalletConnectRouter/Router/Router.swift b/Sources/WalletConnectRouter/Router/Router.swift index 75a227b31..97db89059 100644 --- a/Sources/WalletConnectRouter/Router/Router.swift +++ b/Sources/WalletConnectRouter/Router/Router.swift @@ -1,5 +1,5 @@ +#if os(iOS) import UIKit - public struct WalletConnectRouter { public static func goBack(uri: String) { if #available(iOS 17, *) { @@ -13,3 +13,4 @@ public struct WalletConnectRouter { } } } +#endif diff --git a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift index 8c994c094..899b384cc 100644 --- a/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/ApproveEngine.swift @@ -3,13 +3,14 @@ import Combine final class ApproveEngine { enum Errors: Error { - case wrongRequestParams + case proposalNotFound case relayNotFound - case proposalPayloadsNotFound case pairingNotFound case sessionNotFound case agreementMissingOrInvalid case networkNotConnected + case proposalExpired + case emtySessionNamespacesForbidden } var onSessionProposal: ((Session.Proposal, VerifyContext?) -> Void)? @@ -27,6 +28,7 @@ final class ApproveEngine { private let metadata: AppMetadata private let kms: KeyManagementServiceProtocol private let logger: ConsoleLogging + private let rpcHistory: RPCHistory private var publishers = Set() @@ -41,7 +43,8 @@ final class ApproveEngine { logger: ConsoleLogging, pairingStore: WCPairingStorage, sessionStore: WCSessionStorage, - verifyClient: VerifyClientProtocol + verifyClient: VerifyClientProtocol, + rpcHistory: RPCHistory ) { self.networkingInteractor = networkingInteractor self.proposalPayloadsStore = proposalPayloadsStore @@ -54,6 +57,7 @@ final class ApproveEngine { self.pairingStore = pairingStore self.sessionStore = sessionStore self.verifyClient = verifyClient + self.rpcHistory = rpcHistory setupRequestSubscriptions() setupResponseSubscriptions() @@ -62,21 +66,28 @@ final class ApproveEngine { func approveProposal(proposerPubKey: String, validating sessionNamespaces: [String: SessionNamespace], sessionProperties: [String: String]? = nil) async throws { logger.debug("Approving session proposal") + + guard !sessionNamespaces.isEmpty else { throw Errors.emtySessionNamespacesForbidden } + guard let payload = try proposalPayloadsStore.get(key: proposerPubKey) else { - throw Errors.wrongRequestParams + throw Errors.proposalNotFound } - + + let proposal = payload.request + + guard !proposal.isExpired() else { + logger.debug("Proposal has expired, topic: \(payload.topic)") + proposalPayloadsStore.delete(forKey: proposerPubKey) + throw Errors.proposalExpired + } + let networkConnectionStatus = await resolveNetworkConnectionStatus() guard networkConnectionStatus == .connected else { throw Errors.networkNotConnected } - let proposal = payload.request let pairingTopic = payload.topic - proposalPayloadsStore.delete(forKey: proposerPubKey) - verifyContextStore.delete(forKey: proposerPubKey) - try Namespace.validate(sessionNamespaces) try Namespace.validateApproved(sessionNamespaces, against: proposal.requiredNamespaces) @@ -97,13 +108,13 @@ final class ApproveEngine { let result = SessionType.ProposeResponse(relay: relay, responderPublicKey: selfPublicKey.hexRepresentation) let response = RPCResponse(id: payload.id, result: result) - async let proposeResponse: () = networkingInteractor.respond( + async let proposeResponseTask: () = networkingInteractor.respond( topic: payload.topic, response: response, protocolMethod: SessionProposeProtocolMethod() ) - async let settleRequest: () = settle( + async let settleRequestTask: WCSession = settle( topic: sessionTopic, proposal: proposal, namespaces: sessionNamespaces, @@ -111,10 +122,16 @@ final class ApproveEngine { pairingTopic: pairingTopic ) - _ = try await [proposeResponse, settleRequest] + _ = try await proposeResponseTask + let session: WCSession = try await settleRequestTask + sessionStore.setSession(session) + onSessionSettle?(session.publicRepresentation()) logger.debug("Session proposal response and settle request have been sent") + proposalPayloadsStore.delete(forKey: proposerPubKey) + verifyContextStore.delete(forKey: proposerPubKey) + pairingRegisterer.activate( pairingTopic: payload.topic, peerMetadata: payload.request.proposer.metadata @@ -123,15 +140,23 @@ final class ApproveEngine { func reject(proposerPubKey: String, reason: SignReasonCode) async throws { guard let payload = try proposalPayloadsStore.get(key: proposerPubKey) else { - throw Errors.proposalPayloadsNotFound + throw Errors.proposalNotFound } + + try await networkingInteractor.respondError(topic: payload.topic, requestId: payload.id, protocolMethod: SessionProposeProtocolMethod(), reason: reason) + + if let pairingTopic = rpcHistory.get(recordId: payload.id)?.topic, + let pairing = pairingStore.getPairing(forTopic: pairingTopic), + !pairing.active { + pairingStore.delete(topic: pairingTopic) + } + proposalPayloadsStore.delete(forKey: proposerPubKey) verifyContextStore.delete(forKey: proposerPubKey) - try await networkingInteractor.respondError(topic: payload.topic, requestId: payload.id, protocolMethod: SessionProposeProtocolMethod(), reason: reason) - // TODO: Delete pairing if inactive + } - func settle(topic: String, proposal: SessionProposal, namespaces: [String: SessionNamespace], sessionProperties: [String: String]? = nil, pairingTopic: String) async throws { + func settle(topic: String, proposal: SessionProposal, namespaces: [String: SessionNamespace], sessionProperties: [String: String]? = nil, pairingTopic: String) async throws -> WCSession { guard let agreementKeys = kms.getAgreementSecret(for: topic) else { throw Errors.agreementMissingOrInvalid } @@ -169,7 +194,6 @@ final class ApproveEngine { logger.debug("Sending session settle request") - sessionStore.setSession(session) let protocolMethod = SessionSettleProtocolMethod() let request = RPCRequest(method: protocolMethod.method, params: settleParams) @@ -178,7 +202,7 @@ final class ApproveEngine { async let settleRequest: () = networkingInteractor.request(request, topic: topic, protocolMethod: protocolMethod) _ = try await [settleRequest, subscription] - onSessionSettle?(session.publicRepresentation()) + return session } } @@ -408,8 +432,22 @@ private extension ApproveEngine { extension ApproveEngine.Errors: LocalizedError { var errorDescription: String? { switch self { - case .networkNotConnected: return "Action failed. You seem to be offline" - default: return "" + case .proposalNotFound: + return "Proposal not found." + case .relayNotFound: + return "Relay not found." + case .pairingNotFound: + return "Pairing not found." + case .sessionNotFound: + return "Session not found." + case .agreementMissingOrInvalid: + return "Agreement missing or invalid." + case .networkNotConnected: + return "Network not connected." + case .proposalExpired: + return "Proposal expired." + case .emtySessionNamespacesForbidden: + return "Session Namespaces Cannot Be Empty" } } } diff --git a/Sources/WalletConnectSign/Engine/Common/PendingProposalsProvider.swift b/Sources/WalletConnectSign/Engine/Common/PendingProposalsProvider.swift new file mode 100644 index 000000000..4af7e3808 --- /dev/null +++ b/Sources/WalletConnectSign/Engine/Common/PendingProposalsProvider.swift @@ -0,0 +1,49 @@ +import Foundation +import Combine + +class PendingProposalsProvider { + + private let proposalPayloadsStore: CodableStore> + private let verifyContextStore: CodableStore + private var publishers = Set() + private let pendingProposalsPublisherSubject = CurrentValueSubject<[(proposal: Session.Proposal, context: VerifyContext?)], Never>([]) + + var pendingProposalsPublisher: AnyPublisher<[(proposal: Session.Proposal, context: VerifyContext?)], Never> { + return pendingProposalsPublisherSubject.eraseToAnyPublisher() + } + + internal init( + proposalPayloadsStore: CodableStore>, + verifyContextStore: CodableStore) + { + self.proposalPayloadsStore = proposalPayloadsStore + self.verifyContextStore = verifyContextStore + updatePendingProposals() + setUpPendingProposalsPublisher() + } + + private func updatePendingProposals() { + let proposalsWithVerifyContext = getPendingProposals() + pendingProposalsPublisherSubject.send(proposalsWithVerifyContext) + } + + func setUpPendingProposalsPublisher() { + proposalPayloadsStore.storeUpdatePublisher.sink { [unowned self] _ in + updatePendingProposals() + }.store(in: &publishers) + } + + private func getPendingProposals() -> [(proposal: Session.Proposal, context: VerifyContext?)] { + let proposals = proposalPayloadsStore.getAll() + return proposals.map { ($0.request.publicRepresentation(pairingTopic: $0.topic), try? verifyContextStore.get(key: $0.request.proposer.publicKey)) } + } + + public func getPendingProposals(topic: String? = nil) -> [(proposal: Session.Proposal, context: VerifyContext?)] { + if let topic = topic { + return getPendingProposals().filter { $0.proposal.pairingTopic == topic } + } else { + return getPendingProposals() + } + } + +} diff --git a/Sources/WalletConnectSign/Engine/Common/ProposalExpiryWatcher.swift b/Sources/WalletConnectSign/Engine/Common/ProposalExpiryWatcher.swift new file mode 100644 index 000000000..94bc407ca --- /dev/null +++ b/Sources/WalletConnectSign/Engine/Common/ProposalExpiryWatcher.swift @@ -0,0 +1,41 @@ +import Foundation +import Combine + +class ProposalExpiryWatcher { + + private let sessionProposalExpirationPublisherSubject: PassthroughSubject = .init() + private let rpcHistory: RPCHistory + + var sessionProposalExpirationPublisher: AnyPublisher { + return sessionProposalExpirationPublisherSubject.eraseToAnyPublisher() + } + + private let proposalPayloadsStore: CodableStore> + private var checkTimer: Timer? + + internal init( + proposalPayloadsStore: CodableStore>, + rpcHistory: RPCHistory + ) { + self.proposalPayloadsStore = proposalPayloadsStore + self.rpcHistory = rpcHistory + setUpExpiryCheckTimer() + } + + func setUpExpiryCheckTimer() { + checkTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [unowned self] _ in + checkForProposalsExpiry() + } + } + + func checkForProposalsExpiry() { + let proposals = proposalPayloadsStore.getAll() + proposals.forEach { proposalPayload in + let pairingTopic = proposalPayload.topic + guard proposalPayload.request.isExpired() else { return } + sessionProposalExpirationPublisherSubject.send(proposalPayload.request.publicRepresentation(pairingTopic: pairingTopic)) + proposalPayloadsStore.delete(forKey: proposalPayload.request.proposer.publicKey) + rpcHistory.delete(id: proposalPayload.id) + } + } +} diff --git a/Sources/WalletConnectSign/Engine/Common/RequestsExpiryWatcher.swift b/Sources/WalletConnectSign/Engine/Common/RequestsExpiryWatcher.swift new file mode 100644 index 000000000..a1a2726df --- /dev/null +++ b/Sources/WalletConnectSign/Engine/Common/RequestsExpiryWatcher.swift @@ -0,0 +1,41 @@ + +import Foundation +import Combine + +class RequestsExpiryWatcher { + + private let requestExpirationPublisherSubject: PassthroughSubject = .init() + private let rpcHistory: RPCHistory + private let historyService: HistoryService + + var requestExpirationPublisher: AnyPublisher { + return requestExpirationPublisherSubject.eraseToAnyPublisher() + } + + private var checkTimer: Timer? + + internal init( + proposalPayloadsStore: CodableStore>, + rpcHistory: RPCHistory, + historyService: HistoryService + ) { + self.rpcHistory = rpcHistory + self.historyService = historyService + setUpExpiryCheckTimer() + } + + func setUpExpiryCheckTimer() { + checkTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [unowned self] _ in + checkForRequestExpiry() + } + } + + func checkForRequestExpiry() { + let requests = historyService.getPendingRequestsWithRecordId() + requests.forEach { (request: Request, recordId: RPCID) in + guard request.isExpired() else { return } + requestExpirationPublisherSubject.send(request) + rpcHistory.delete(id: recordId) + } + } +} diff --git a/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift b/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift index f2598b074..9d0c034f6 100644 --- a/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift +++ b/Sources/WalletConnectSign/Engine/Common/SessionEngine.swift @@ -63,9 +63,10 @@ final class SessionEngine { guard session.hasPermission(forMethod: request.method, onChain: request.chainId) else { throw WalletConnectError.invalidPermissions } - let chainRequest = SessionType.RequestParams.Request(method: request.method, params: request.params, expiry: request.expiry) + let chainRequest = SessionType.RequestParams.Request(method: request.method, params: request.params, expiryTimestamp: request.expiryTimestamp) let sessionRequestParams = SessionType.RequestParams(request: chainRequest, chainId: request.chainId) - let protocolMethod = SessionRequestProtocolMethod(ttl: request.calculateTtl()) + let ttl = try request.calculateTtl() + let protocolMethod = SessionRequestProtocolMethod(ttl: ttl) let rpcRequest = RPCRequest(method: protocolMethod.method, params: sessionRequestParams, rpcid: request.id) try await networkingInteractor.request(rpcRequest, topic: request.topic, protocolMethod: SessionRequestProtocolMethod()) } @@ -228,7 +229,7 @@ private extension SessionEngine { method: payload.request.request.method, params: payload.request.request.params, chainId: payload.request.chainId, - expiry: payload.request.request.expiry + expiryTimestamp: payload.request.request.expiryTimestamp ) guard let session = sessionStore.getSession(forTopic: topic) else { return respondError(payload: payload, reason: .noSessionForTopic, protocolMethod: protocolMethod) diff --git a/Sources/WalletConnectSign/Namespace.swift b/Sources/WalletConnectSign/Namespace.swift index 6de73e1cc..08ded8326 100644 --- a/Sources/WalletConnectSign/Namespace.swift +++ b/Sources/WalletConnectSign/Namespace.swift @@ -1,8 +1,26 @@ -public enum AutoNamespacesError: Error { +import Foundation + +public enum AutoNamespacesError: Error, LocalizedError { case requiredChainsNotSatisfied case requiredAccountsNotSatisfied case requiredMethodsNotSatisfied case requiredEventsNotSatisfied + case emtySessionNamespacesForbidden + + public var errorDescription: String? { + switch self { + case .requiredChainsNotSatisfied: + return "The required chains are not satisfied." + case .requiredAccountsNotSatisfied: + return "The required accounts are not satisfied." + case .requiredMethodsNotSatisfied: + return "The required methods are not satisfied." + case .requiredEventsNotSatisfied: + return "The required events are not satisfied." + case .emtySessionNamespacesForbidden: + return "Empty session namespaces are not allowed." + } + } } public struct ProposalNamespace: Equatable, Codable { @@ -148,9 +166,6 @@ enum SessionProperties { public enum AutoNamespaces { /// For a wallet to build session proposal structure by provided supported chains, methods, events & accounts. - /// - Parameters: - /// - proposalId: Session Proposal id - /// - namespaces: namespaces for given session, needs to contain at least required namespaces proposed by dApp. public static func build( sessionProposal: Session.Proposal, chains: [Blockchain], @@ -325,7 +340,8 @@ public enum AutoNamespaces { } } } - + guard !sessionNamespaces.isEmpty else { throw AutoNamespacesError.emtySessionNamespacesForbidden } + return sessionNamespaces } } diff --git a/Sources/WalletConnectSign/Request.swift b/Sources/WalletConnectSign/Request.swift index 1cae4e0cd..3898d45df 100644 --- a/Sources/WalletConnectSign/Request.swift +++ b/Sources/WalletConnectSign/Request.swift @@ -1,62 +1,72 @@ import Foundation public struct Request: Codable, Equatable { + public enum Errors: Error { + case invalidTtl + case requestExpired + } + public let id: RPCID public let topic: String public let method: String public let params: AnyCodable public let chainId: Blockchain - public let expiry: UInt64? + public var expiryTimestamp: UInt64? + + // TTL bounds + static let minTtl: TimeInterval = 300 // 5 minutes + static let maxTtl: TimeInterval = 604800 // 7 days + + + /// - Parameters: + /// - topic: topic of a session + /// - method: request method + /// - params: request params + /// - chainId: chain id + /// - ttl: ttl of a request, will be used to calculate expiry, 10 minutes by default + public init(topic: String, method: String, params: AnyCodable, chainId: Blockchain, ttl: TimeInterval = 300) throws { + guard ttl >= Request.minTtl && ttl <= Request.maxTtl else { + throw Errors.invalidTtl + } + + let calculatedExpiry = UInt64(Date().timeIntervalSince1970) + UInt64(ttl) + self.init(id: RPCID(JsonRpcID.generate()), topic: topic, method: method, params: params, chainId: chainId, expiryTimestamp: calculatedExpiry) + } - internal init(id: RPCID, topic: String, method: String, params: AnyCodable, chainId: Blockchain, expiry: UInt64?) { + init(id: RPCID, topic: String, method: String, params: C, chainId: Blockchain, ttl: TimeInterval = 300) throws where C: Codable { + guard ttl >= Request.minTtl && ttl <= Request.maxTtl else { + throw Errors.invalidTtl + } + + let calculatedExpiry = UInt64(Date().timeIntervalSince1970) + UInt64(ttl) + self.init(id: id, topic: topic, method: method, params: AnyCodable(params), chainId: chainId, expiryTimestamp: calculatedExpiry) + } + + internal init(id: RPCID, topic: String, method: String, params: AnyCodable, chainId: Blockchain, expiryTimestamp: UInt64?) { self.id = id self.topic = topic self.method = method self.params = params self.chainId = chainId - self.expiry = expiry - } - - public init(topic: String, method: String, params: AnyCodable, chainId: Blockchain, expiry: UInt64? = nil) { - self.init(id: RPCID(JsonRpcID.generate()), topic: topic, method: method, params: params, chainId: chainId, expiry: expiry) - } - - init(id: RPCID, topic: String, method: String, params: C, chainId: Blockchain, expiry: UInt64?) where C: Codable { - self.init(id: id, topic: topic, method: method, params: AnyCodable(params), chainId: chainId, expiry: expiry) + self.expiryTimestamp = expiryTimestamp } func isExpired(currentDate: Date = Date()) -> Bool { - guard let expiry = expiry else { return false } - + guard let expiry = expiryTimestamp else { return false } let expiryDate = Date(timeIntervalSince1970: TimeInterval(expiry)) - - guard - abs(currentDate.distance(to: expiryDate)) < Constants.maxExpiry, - abs(currentDate.distance(to: expiryDate)) > Constants.minExpiry - else { return true } - return expiryDate < currentDate } - func calculateTtl(currentDate: Date = Date()) -> Int { - guard let expiry = expiry else { return SessionRequestProtocolMethod.defaultTtl } - + func calculateTtl(currentDate: Date = Date()) throws -> Int { + guard let expiry = expiryTimestamp else { return Int(Self.minTtl) } + let expiryDate = Date(timeIntervalSince1970: TimeInterval(expiry)) - let diff = expiryDate - currentDate.timeIntervalSince1970 - - guard - diff.timeIntervalSince1970 < Constants.maxExpiry, - diff.timeIntervalSince1970 > Constants.minExpiry - else { return SessionRequestProtocolMethod.defaultTtl } - - return Int(diff.timeIntervalSince1970) - } -} + let diff = expiryDate.timeIntervalSince(currentDate) -private extension Request { + guard diff > 0 else { + throw Errors.requestExpired + } - struct Constants { - static let minExpiry: TimeInterval = 300 // 5 minutes - static let maxExpiry: TimeInterval = 604800 // 7 days + return Int(diff) } } diff --git a/Sources/WalletConnectSign/Services/HistoryService.swift b/Sources/WalletConnectSign/Services/HistoryService.swift index 2a5974471..a34f52bb5 100644 --- a/Sources/WalletConnectSign/Services/HistoryService.swift +++ b/Sources/WalletConnectSign/Services/HistoryService.swift @@ -3,98 +3,56 @@ import Foundation final class HistoryService { private let history: RPCHistory - private let proposalPayloadsStore: CodableStore> private let verifyContextStore: CodableStore init( history: RPCHistory, - proposalPayloadsStore: CodableStore>, verifyContextStore: CodableStore ) { self.history = history - self.proposalPayloadsStore = proposalPayloadsStore self.verifyContextStore = verifyContextStore } public func getSessionRequest(id: RPCID) -> (request: Request, context: VerifyContext?)? { guard let record = history.get(recordId: id) else { return nil } - guard let request = mapRequestRecord(record) else { + guard let (request, recordId) = mapRequestRecord(record) else { return nil } - return (request, try? verifyContextStore.get(key: request.id.string)) + return (request, try? verifyContextStore.get(key: recordId.string)) } - + func getPendingRequests() -> [(request: Request, context: VerifyContext?)] { let requests = history.getPending() .compactMap { mapRequestRecord($0) } - .filter { !$0.isExpired() } - return requests.map { ($0, try? verifyContextStore.get(key: $0.id.string)) } + .filter { !$0.0.isExpired() } // Note the change here to access the Request part of the tuple + return requests.map { (request: $0.0, context: try? verifyContextStore.get(key: $0.1.string)) } + } + + + func getPendingRequestsWithRecordId() -> [(request: Request, recordId: RPCID)] { + history.getPending() + .compactMap { mapRequestRecord($0) } } func getPendingRequests(topic: String) -> [(request: Request, context: VerifyContext?)] { return getPendingRequests().filter { $0.request.topic == topic } } - - func getPendingProposals() -> [(proposal: Session.Proposal, context: VerifyContext?)] { - let pendingHistory = history.getPending() - - let requestSubscriptionPayloads = pendingHistory - .compactMap { record -> RequestSubscriptionPayload? in - guard let proposalParams = mapProposeParams(record) else { - return nil - } - return RequestSubscriptionPayload(id: record.id, topic: record.topic, request: proposalParams, decryptedPayload: Data(), publishedAt: Date(), derivedTopic: nil) - } - - requestSubscriptionPayloads.forEach { - let proposal = $0.request - proposalPayloadsStore.set($0, forKey: proposal.proposer.publicKey) - } - - let proposals = pendingHistory - .compactMap { mapProposalRecord($0) } - - return proposals.map { ($0, try? verifyContextStore.get(key: $0.proposal.proposer.publicKey)) } - } - - func getPendingProposals(topic: String) -> [(proposal: Session.Proposal, context: VerifyContext?)] { - return getPendingProposals().filter { $0.proposal.pairingTopic == topic } - } } private extension HistoryService { - func mapRequestRecord(_ record: RPCHistory.Record) -> Request? { + func mapRequestRecord(_ record: RPCHistory.Record) -> (Request, RPCID)? { guard let request = try? record.request.params?.get(SessionType.RequestParams.self) else { return nil } - return Request( + let mappedRequest = Request( id: record.id, topic: record.topic, method: request.request.method, params: request.request.params, chainId: request.chainId, - expiry: request.request.expiry - ) - } - - func mapProposeParams(_ record: RPCHistory.Record) -> SessionType.ProposeParams? { - guard let proposal = try? record.request.params?.get(SessionType.ProposeParams.self) - else { return nil } - return proposal - } - - func mapProposalRecord(_ record: RPCHistory.Record) -> Session.Proposal? { - guard let proposal = try? record.request.params?.get(SessionType.ProposeParams.self) - else { return nil } - - return Session.Proposal( - id: proposal.proposer.publicKey, - pairingTopic: record.topic, - proposer: proposal.proposer.metadata, - requiredNamespaces: proposal.requiredNamespaces, - optionalNamespaces: proposal.optionalNamespaces ?? [:], - sessionProperties: proposal.sessionProperties, - proposal: proposal + expiryTimestamp: request.request.expiryTimestamp ) + + return (mappedRequest, record.id) } } diff --git a/Sources/WalletConnectSign/Session.swift b/Sources/WalletConnectSign/Session.swift index 7e0d24fb4..06b2500f7 100644 --- a/Sources/WalletConnectSign/Session.swift +++ b/Sources/WalletConnectSign/Session.swift @@ -28,7 +28,11 @@ extension Session { // TODO: Refactor internal objects to manage only needed data internal let proposal: SessionProposal - + + func isExpired() -> Bool { + return proposal.isExpired() + } + init( id: String, pairingTopic: String, diff --git a/Sources/WalletConnectSign/Sign/SignClient.swift b/Sources/WalletConnectSign/Sign/SignClient.swift index b87864de1..9c4b5e14d 100644 --- a/Sources/WalletConnectSign/Sign/SignClient.swift +++ b/Sources/WalletConnectSign/Sign/SignClient.swift @@ -99,6 +99,20 @@ public final class SignClient: SignClientProtocol { return logger.logsPublisher } + /// Publisher that sends session proposal expiration + public var sessionProposalExpirationPublisher: AnyPublisher { + return proposalExpiryWatcher.sessionProposalExpirationPublisher + } + + public var pendingProposalsPublisher: AnyPublisher<[(proposal: Session.Proposal, context: VerifyContext?)], Never> { + return pendingProposalsProvider.pendingProposalsPublisher + } + + public var requestExpirationPublisher: AnyPublisher { + return requestsExpiryWatcher.requestExpirationPublisher + } + + /// An object that loggs SDK's errors and info messages public let logger: ConsoleLogging @@ -119,6 +133,9 @@ public final class SignClient: SignClientProtocol { private let appProposeService: AppProposeService private let historyService: HistoryService private let cleanupService: SignCleanupService + private let proposalExpiryWatcher: ProposalExpiryWatcher + private let pendingProposalsProvider: PendingProposalsProvider + private let requestsExpiryWatcher: RequestsExpiryWatcher private let sessionProposalPublisherSubject = PassthroughSubject<(proposal: Session.Proposal, context: VerifyContext?), Never>() private let sessionRequestPublisherSubject = PassthroughSubject<(request: Request, context: VerifyContext?), Never>() @@ -152,7 +169,10 @@ public final class SignClient: SignClientProtocol { disconnectService: DisconnectService, historyService: HistoryService, cleanupService: SignCleanupService, - pairingClient: PairingClient + pairingClient: PairingClient, + proposalExpiryWatcher: ProposalExpiryWatcher, + pendingProposalsProvider: PendingProposalsProvider, + requestsExpiryWatcher: RequestsExpiryWatcher ) { self.logger = logger self.networkingClient = networkingClient @@ -170,6 +190,9 @@ public final class SignClient: SignClientProtocol { self.cleanupService = cleanupService self.disconnectService = disconnectService self.pairingClient = pairingClient + self.proposalExpiryWatcher = proposalExpiryWatcher + self.pendingProposalsProvider = pendingProposalsProvider + self.requestsExpiryWatcher = requestsExpiryWatcher setUpConnectionObserving() setUpEnginesCallbacks() @@ -303,21 +326,9 @@ public final class SignClient: SignClientProtocol { return historyService.getPendingRequests() } } - - /// Query pending proposals - /// - Returns: Pending proposals received from peer with `wc_sessionPropose` protocol method - public func getPendingProposals(topic: String? = nil) -> [(proposal: Session.Proposal, context: VerifyContext?)] { - if let topic = topic { - return historyService.getPendingProposals(topic: topic) - } else { - return historyService.getPendingProposals() - } - } - /// - Parameter id: id of a wc_sessionRequest jsonrpc request - /// - Returns: json rpc record object for given id or nil if record for give id does not exits - public func getSessionRequestRecord(id: RPCID) -> (request: Request, context: VerifyContext?)? { - return historyService.getSessionRequest(id: id) + public func getPendingProposals(topic: String? = nil) -> [(proposal: Session.Proposal, context: VerifyContext?)] { + pendingProposalsProvider.getPendingProposals() } /// Delete all stored data such as: pairings, sessions, keys diff --git a/Sources/WalletConnectSign/Sign/SignClientFactory.swift b/Sources/WalletConnectSign/Sign/SignClientFactory.swift index 1d9adf073..3d63bcf39 100644 --- a/Sources/WalletConnectSign/Sign/SignClientFactory.swift +++ b/Sources/WalletConnectSign/Sign/SignClientFactory.swift @@ -41,7 +41,7 @@ public struct SignClientFactory { let sessionStore = SessionStorage(storage: SequenceStore(store: .init(defaults: keyValueStorage, identifier: SignStorageIdentifiers.sessions.rawValue))) let proposalPayloadsStore = CodableStore>(defaults: RuntimeKeyValueStorage(), identifier: SignStorageIdentifiers.proposals.rawValue) let verifyContextStore = CodableStore(defaults: keyValueStorage, identifier: VerifyStorageIdentifiers.context.rawValue) - let historyService = HistoryService(history: rpcHistory, proposalPayloadsStore: proposalPayloadsStore, verifyContextStore: verifyContextStore) + let historyService = HistoryService(history: rpcHistory, verifyContextStore: verifyContextStore) let verifyClient = VerifyClientFactory.create() let sessionEngine = SessionEngine(networkingInteractor: networkingClient, historyService: historyService, verifyContextStore: verifyContextStore, verifyClient: verifyClient, kms: kms, sessionStore: sessionStore, logger: logger) let nonControllerSessionStateMachine = NonControllerSessionStateMachine(networkingInteractor: networkingClient, kms: kms, sessionStore: sessionStore, logger: logger) @@ -61,7 +61,8 @@ public struct SignClientFactory { logger: logger, pairingStore: pairingStore, sessionStore: sessionStore, - verifyClient: verifyClient + verifyClient: verifyClient, + rpcHistory: rpcHistory ) let cleanupService = SignCleanupService(pairingStore: pairingStore, sessionStore: sessionStore, kms: kms, sessionTopicToProposal: sessionTopicToProposal, networkInteractor: networkingClient) let deleteSessionService = DeleteSessionService(networkingInteractor: networkingClient, kms: kms, sessionStore: sessionStore, logger: logger) @@ -69,6 +70,9 @@ public struct SignClientFactory { let sessionPingService = SessionPingService(sessionStorage: sessionStore, networkingInteractor: networkingClient, logger: logger) let pairingPingService = PairingPingService(pairingStorage: pairingStore, networkingInteractor: networkingClient, logger: logger) let appProposerService = AppProposeService(metadata: metadata, networkingInteractor: networkingClient, kms: kms, logger: logger) + let proposalExpiryWatcher = ProposalExpiryWatcher(proposalPayloadsStore: proposalPayloadsStore, rpcHistory: rpcHistory) + let pendingProposalsProvider = PendingProposalsProvider(proposalPayloadsStore: proposalPayloadsStore, verifyContextStore: verifyContextStore) + let requestsExpiryWatcher = RequestsExpiryWatcher(proposalPayloadsStore: proposalPayloadsStore, rpcHistory: rpcHistory, historyService: historyService) let client = SignClient( logger: logger, @@ -86,7 +90,10 @@ public struct SignClientFactory { disconnectService: disconnectService, historyService: historyService, cleanupService: cleanupService, - pairingClient: pairingClient + pairingClient: pairingClient, + proposalExpiryWatcher: proposalExpiryWatcher, + pendingProposalsProvider: pendingProposalsProvider, + requestsExpiryWatcher: requestsExpiryWatcher ) return client } diff --git a/Sources/WalletConnectSign/Sign/SignClientProtocol.swift b/Sources/WalletConnectSign/Sign/SignClientProtocol.swift index 2b174ddec..4aecfac04 100644 --- a/Sources/WalletConnectSign/Sign/SignClientProtocol.swift +++ b/Sources/WalletConnectSign/Sign/SignClientProtocol.swift @@ -12,6 +12,9 @@ public protocol SignClientProtocol { var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { get } var sessionEventPublisher: AnyPublisher<(event: Session.Event, sessionTopic: String, chainId: Blockchain?), Never> { get } var logsPublisher: AnyPublisher {get} + var sessionProposalExpirationPublisher: AnyPublisher { get } + var pendingProposalsPublisher: AnyPublisher<[(proposal: Session.Proposal, context: VerifyContext?)], Never> { get } + var requestExpirationPublisher: AnyPublisher { get } func connect(requiredNamespaces: [String: ProposalNamespace], optionalNamespaces: [String: ProposalNamespace]?, sessionProperties: [String: String]?, topic: String) async throws func request(params: Request) async throws @@ -27,5 +30,5 @@ public protocol SignClientProtocol { func getPendingRequests(topic: String?) -> [(request: Request, context: VerifyContext?)] func getPendingProposals(topic: String?) -> [(proposal: Session.Proposal, context: VerifyContext?)] - func getSessionRequestRecord(id: RPCID) -> (request: Request, context: VerifyContext?)? } + diff --git a/Sources/WalletConnectSign/SignDecryptionService.swift b/Sources/WalletConnectSign/SignDecryptionService.swift index 8b0e82125..588d07337 100644 --- a/Sources/WalletConnectSign/SignDecryptionService.swift +++ b/Sources/WalletConnectSign/SignDecryptionService.swift @@ -37,7 +37,7 @@ public class SignDecryptionService { method: request.request.method, params: request.request.params, chainId: request.chainId, - expiry: request.request.expiry + expiryTimestamp: request.request.expiryTimestamp ) return request diff --git a/Sources/WalletConnectSign/Types/Session/SessionProposal.swift b/Sources/WalletConnectSign/Types/Session/SessionProposal.swift index fa1ee979a..2723ef46c 100644 --- a/Sources/WalletConnectSign/Types/Session/SessionProposal.swift +++ b/Sources/WalletConnectSign/Types/Session/SessionProposal.swift @@ -1,11 +1,28 @@ import Foundation struct SessionProposal: Codable, Equatable { + let relays: [RelayProtocolOptions] let proposer: Participant let requiredNamespaces: [String: ProposalNamespace] let optionalNamespaces: [String: ProposalNamespace]? let sessionProperties: [String: String]? + let expiryTimestamp: UInt64? + + static let proposalTtl: TimeInterval = 300 // 5 minutes + + internal init(relays: [RelayProtocolOptions], + proposer: Participant, + requiredNamespaces: [String : ProposalNamespace], + optionalNamespaces: [String : ProposalNamespace]? = nil, + sessionProperties: [String : String]? = nil) { + self.relays = relays + self.proposer = proposer + self.requiredNamespaces = requiredNamespaces + self.optionalNamespaces = optionalNamespaces + self.sessionProperties = sessionProperties + self.expiryTimestamp = UInt64(Date().timeIntervalSince1970 + Self.proposalTtl) + } func publicRepresentation(pairingTopic: String) -> Session.Proposal { return Session.Proposal( @@ -18,4 +35,12 @@ struct SessionProposal: Codable, Equatable { proposal: self ) } + + func isExpired(currentDate: Date = Date()) -> Bool { + guard let expiry = expiryTimestamp else { return false } + + let expiryDate = Date(timeIntervalSince1970: TimeInterval(expiry)) + + return expiryDate < currentDate + } } diff --git a/Sources/WalletConnectSign/Types/Session/SessionType.swift b/Sources/WalletConnectSign/Types/Session/SessionType.swift index cc838f084..d4411aa9a 100644 --- a/Sources/WalletConnectSign/Types/Session/SessionType.swift +++ b/Sources/WalletConnectSign/Types/Session/SessionType.swift @@ -43,7 +43,7 @@ internal enum SessionType { struct Request: Codable, Equatable { let method: String let params: AnyCodable - let expiry: UInt64? + let expiryTimestamp: UInt64? } } diff --git a/Sources/WalletConnectSign/Types/Session/WCSession.swift b/Sources/WalletConnectSign/Types/Session/WCSession.swift index 30d237a85..a24b13691 100644 --- a/Sources/WalletConnectSign/Types/Session/WCSession.swift +++ b/Sources/WalletConnectSign/Types/Session/WCSession.swift @@ -65,7 +65,7 @@ struct WCSession: SequenceObject, Equatable { events: Set, accounts: Set, acknowledged: Bool, - expiry: Int64 + expiryTimestamp: Int64 ) { self.topic = topic self.pairingTopic = pairingTopic @@ -78,7 +78,7 @@ struct WCSession: SequenceObject, Equatable { self.sessionProperties = sessionProperties self.requiredNamespaces = requiredNamespaces self.acknowledged = acknowledged - self.expiryDate = Date(timeIntervalSince1970: TimeInterval(expiry)) + self.expiryDate = Date(timeIntervalSince1970: TimeInterval(expiryTimestamp)) } #endif diff --git a/Sources/WalletConnectUtils/RPCHistory/RPCHistory.swift b/Sources/WalletConnectUtils/RPCHistory/RPCHistory.swift index b310586d0..274122c3d 100644 --- a/Sources/WalletConnectUtils/RPCHistory/RPCHistory.swift +++ b/Sources/WalletConnectUtils/RPCHistory/RPCHistory.swift @@ -15,14 +15,29 @@ public final class RPCHistory { public var timestamp: Date? } - enum HistoryError: Error { + enum HistoryError: Error, LocalizedError { case unidentifiedRequest case unidentifiedResponse case requestDuplicateNotAllowed case responseDuplicateNotAllowed case requestMatchingResponseNotFound + var errorDescription: String? { + switch self { + case .unidentifiedRequest: + return "Unidentified request." + case .unidentifiedResponse: + return "Unidentified response." + case .requestDuplicateNotAllowed: + return "Request duplicates are not allowed." + case .responseDuplicateNotAllowed: + return "Response duplicates are not allowed." + case .requestMatchingResponseNotFound: + return "Matching requesr for the response not found." + } + } } + private let storage: CodableStore init(keyValueStore: CodableStore) { diff --git a/Sources/WalletConnectUtils/RelayProtocolOptions.swift b/Sources/WalletConnectUtils/RelayProtocolOptions.swift index e437e9342..5a19d9f05 100644 --- a/Sources/WalletConnectUtils/RelayProtocolOptions.swift +++ b/Sources/WalletConnectUtils/RelayProtocolOptions.swift @@ -9,3 +9,11 @@ public struct RelayProtocolOptions: Codable, Equatable { self.data = data } } + +#if DEBUG +public extension RelayProtocolOptions { + static func stub() -> RelayProtocolOptions { + RelayProtocolOptions(protocol: "", data: nil) + } +} +#endif diff --git a/Sources/WalletConnectUtils/SequenceStore.swift b/Sources/WalletConnectUtils/SequenceStore.swift index 618ba411e..94591af29 100644 --- a/Sources/WalletConnectUtils/SequenceStore.swift +++ b/Sources/WalletConnectUtils/SequenceStore.swift @@ -12,10 +12,13 @@ public final class SequenceStore where T: SequenceObject { private let store: CodableStore private let dateInitializer: () -> Date + private var expiryMonitorTimer: Timer? + public init(store: CodableStore, dateInitializer: @escaping () -> Date = Date.init) { self.store = store self.dateInitializer = dateInitializer + startExpiryMonitor() } public func hasSequence(forTopic topic: String) -> Bool { @@ -46,6 +49,19 @@ public final class SequenceStore where T: SequenceObject { store.deleteAll() onSequenceUpdate?() } + + // MARK: Expiry Monitor + + private func startExpiryMonitor() { + expiryMonitorTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + self?.checkAllSequencesForExpiry() + } + } + + private func checkAllSequencesForExpiry() { + let allSequences = getAll() + allSequences.forEach { _ = verifyExpiry(on: $0) } + } } // MARK: Privates diff --git a/Sources/WalletConnectUtils/WalletConnectURI.swift b/Sources/WalletConnectUtils/WalletConnectURI.swift index 2f068a67f..4e8701b50 100644 --- a/Sources/WalletConnectUtils/WalletConnectURI.swift +++ b/Sources/WalletConnectUtils/WalletConnectURI.swift @@ -1,13 +1,18 @@ import Foundation public struct WalletConnectURI: Equatable { + public enum Errors: Error { + case expired + case invalidFormat + } public let topic: String public let version: String public let symKey: String public let relay: RelayProtocolOptions + public let expiryTimestamp: UInt64 public var absoluteString: String { - return "wc:\(topic)@\(version)?symKey=\(symKey)&\(relayQuery)" + return "wc:\(topic)@\(version)?symKey=\(symKey)&\(relayQuery)&expiryTimestamp=\(expiryTimestamp)" } public var deeplinkUri: String { @@ -20,12 +25,26 @@ public struct WalletConnectURI: Equatable { self.topic = topic self.symKey = symKey self.relay = relay + + // Only after all properties are initialized, you can use self or its methods + self.expiryTimestamp = fiveMinutesFromNow } + @available(*, deprecated, message: "Use the throwing initializer instead") public init?(string: String) { - guard let components = Self.parseURIComponents(from: string) else { + do { + try self.init(uriString: string) + } catch { + print("Initialization failed: \(error.localizedDescription)") return nil } + } + + public init(uriString: String) throws { + let decodedString = uriString.removingPercentEncoding ?? uriString + guard let components = Self.parseURIComponents(from: decodedString) else { + throw Errors.invalidFormat + } let query: [String: String]? = components.queryItems?.reduce(into: [:]) { $0[$1.name] = $1.value } guard @@ -34,22 +53,31 @@ public struct WalletConnectURI: Equatable { let symKey = query?["symKey"], let relayProtocol = query?["relay-protocol"] else { - return nil + throw Errors.invalidFormat } + let relayData = query?["relay-data"] + // Check if expiryTimestamp is provided and valid + if let expiryTimestampString = query?["expiryTimestamp"], + let expiryTimestamp = UInt64(expiryTimestampString), + expiryTimestamp <= UInt64(Date().timeIntervalSince1970) { + throw Errors.expired + } + self.version = version self.topic = topic self.symKey = symKey self.relay = RelayProtocolOptions(protocol: relayProtocol, data: relayData) + // Set expiryTimestamp to 5 minutes in the future if not included in the uri + self.expiryTimestamp = UInt64(query?["expiryTimestamp"] ?? "") ?? fiveMinutesFromNow + } - - public init?(deeplinkUri: URL) { - if let deeplinkUri = deeplinkUri.query?.replacingOccurrences(of: "uri=", with: "") { - self.init(string: deeplinkUri) - } else { - return nil - } + + + public init(deeplinkUri: URL) throws { + let uriString = deeplinkUri.query?.replacingOccurrences(of: "uri=", with: "") ?? "" + try self.init(uriString: uriString) } private var relayQuery: String { @@ -61,34 +89,64 @@ public struct WalletConnectURI: Equatable { } private static func parseURIComponents(from string: String) -> URLComponents? { - guard string.hasPrefix("wc:") else { + let decodedString = string.removingPercentEncoding ?? string + guard decodedString.hasPrefix("wc:") else { return nil } - let urlString = !string.hasPrefix("wc://") ? string.replacingOccurrences(of: "wc:", with: "wc://") : string + let urlString = !decodedString.hasPrefix("wc://") ? decodedString.replacingOccurrences(of: "wc:", with: "wc://") : decodedString return URLComponents(string: urlString) } } +extension WalletConnectURI.Errors: LocalizedError { + public var errorDescription: String? { + switch self { + case .expired: + return NSLocalizedString("The WalletConnect Pairing URI has expired.", comment: "Expired URI Error") + case .invalidFormat: + return NSLocalizedString("The format of the WalletConnect Pairing URI is invalid.", comment: "Invalid Format URI Error") + } + } +} + + +fileprivate var fiveMinutesFromNow: UInt64 { + return UInt64(Date().timeIntervalSince1970) + 5 * 60 +} + + #if canImport(UIKit) import UIKit extension WalletConnectURI { - public init?(connectionOptions: UIScene.ConnectionOptions) { + public init(connectionOptions: UIScene.ConnectionOptions) throws { if let uri = connectionOptions.urlContexts.first?.url.query?.replacingOccurrences(of: "uri=", with: "") { - self.init(string: uri) + try self.init(uriString: uri) } else { - return nil + throw Errors.invalidFormat } } - public init?(urlContext: UIOpenURLContext) { + public init(urlContext: UIOpenURLContext) throws { if let uri = urlContext.url.query?.replacingOccurrences(of: "uri=", with: "") { - self.init(string: uri) + try self.init(uriString: uri) } else { - return nil + throw Errors.invalidFormat } } } +#endif +#if DEBUG +extension WalletConnectURI { + init(topic: String, symKey: String, relay: RelayProtocolOptions, expiryTimestamp: UInt64) { + self.version = "2" + self.topic = topic + self.symKey = symKey + self.relay = relay + self.expiryTimestamp = expiryTimestamp + } + +} #endif diff --git a/Sources/Web3Wallet/Web3WalletClient.swift b/Sources/Web3Wallet/Web3WalletClient.swift index 5b70a374a..0930b8443 100644 --- a/Sources/Web3Wallet/Web3WalletClient.swift +++ b/Sources/Web3Wallet/Web3WalletClient.swift @@ -67,12 +67,33 @@ public class Web3WalletClient { pairingClient.pairingDeletePublisher } + public var pairingStatePublisher: AnyPublisher { + pairingClient.pairingStatePublisher + } + + public var pairingExpirationPublisher: AnyPublisher { + return pairingClient.pairingExpirationPublisher + } + public var logsPublisher: AnyPublisher { return signClient.logsPublisher .merge(with: pairingClient.logsPublisher) .eraseToAnyPublisher() } + /// Publisher that sends session proposal expiration + public var sessionProposalExpirationPublisher: AnyPublisher { + return signClient.sessionProposalExpirationPublisher + } + + public var pendingProposalsPublisher: AnyPublisher<[(proposal: Session.Proposal, context: VerifyContext?)], Never> { + return signClient.pendingProposalsPublisher + } + + public var requestExpirationPublisher: AnyPublisher { + return signClient.requestExpirationPublisher + } + // MARK: - Private Properties private let authClient: AuthClientProtocol private let signClient: SignClientProtocol @@ -203,19 +224,11 @@ public class Web3WalletClient { public func getPendingRequests(topic: String? = nil) -> [(request: Request, context: VerifyContext?)] { signClient.getPendingRequests(topic: topic) } - - /// Query pending proposals - /// - Returns: Pending proposals received from peer with `wc_sessionPropose` protocol method + public func getPendingProposals(topic: String? = nil) -> [(proposal: Session.Proposal, context: VerifyContext?)] { signClient.getPendingProposals(topic: topic) } - - /// - Parameter id: id of a wc_sessionRequest jsonrpc request - /// - Returns: json rpc record object for given id or nil if record for give id does not exits - public func getSessionRequestRecord(id: RPCID) -> (request: Request, context: VerifyContext?)? { - signClient.getSessionRequestRecord(id: id) - } - + /// Query pending authentication requests /// - Returns: Pending authentication requests public func getPendingRequests() throws -> [(AuthRequest, VerifyContext?)] { diff --git a/Tests/TestingUtils/Stubs/AppMetadata+Stub.swift b/Tests/TestingUtils/Stubs/AppMetadata+Stub.swift deleted file mode 100644 index ffa368454..000000000 --- a/Tests/TestingUtils/Stubs/AppMetadata+Stub.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation -import WalletConnectPairing - -public extension AppMetadata { - static func stub() -> AppMetadata { - AppMetadata( - name: "Wallet Connect", - description: "A protocol to connect blockchain wallets to dapps.", - url: "https://walletconnect.com/", - icons: [], - redirect: AppMetadata.Redirect(native: "", universal: nil) - ) - } -} diff --git a/Tests/TestingUtils/Stubs/RelayProtocolOptions+Stub.swift b/Tests/TestingUtils/Stubs/RelayProtocolOptions+Stub.swift deleted file mode 100644 index bd3db0839..000000000 --- a/Tests/TestingUtils/Stubs/RelayProtocolOptions+Stub.swift +++ /dev/null @@ -1,8 +0,0 @@ -import WalletConnectUtils - -extension RelayProtocolOptions { - - public static func stub() -> RelayProtocolOptions { - RelayProtocolOptions(protocol: "", data: nil) - } -} diff --git a/Tests/TestingUtils/Stubs/WalletConnectURI+Stub.swift b/Tests/TestingUtils/Stubs/WalletConnectURI+Stub.swift deleted file mode 100644 index 67ee5dfe9..000000000 --- a/Tests/TestingUtils/Stubs/WalletConnectURI+Stub.swift +++ /dev/null @@ -1,13 +0,0 @@ -import WalletConnectKMS -import WalletConnectUtils - -extension WalletConnectURI { - - public static func stub(isController: Bool = false) -> WalletConnectURI { - WalletConnectURI( - topic: String.generateTopic(), - symKey: SymmetricKey().hexRepresentation, - relay: RelayProtocolOptions(protocol: "", data: nil) - ) - } -} diff --git a/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift b/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift index 182e50d0f..bfc9a34b6 100644 --- a/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift +++ b/Tests/WalletConnectModalTests/Mocks/ModalSheetInteractorMock.swift @@ -1,7 +1,7 @@ import Combine import Foundation import WalletConnectSign -import WalletConnectUtils +@testable import WalletConnectUtils @testable import WalletConnectModal @testable import WalletConnectSign @@ -18,7 +18,7 @@ final class ModalSheetInteractorMock: ModalSheetInteractor { } func createPairingAndConnect() async throws -> WalletConnectURI? { - .init(topic: "foo", symKey: "bar", relay: .init(protocol: "irn", data: nil)) + .init(topic: "foo", symKey: "bar", relay: .init(protocol: "irn", data: nil), expiryTimestamp: 1706001526) } var sessionSettlePublisher: AnyPublisher { diff --git a/Tests/WalletConnectModalTests/ModalViewModelTests.swift b/Tests/WalletConnectModalTests/ModalViewModelTests.swift index 55de25cc9..2b9fd7c89 100644 --- a/Tests/WalletConnectModalTests/ModalViewModelTests.swift +++ b/Tests/WalletConnectModalTests/ModalViewModelTests.swift @@ -82,7 +82,7 @@ final class ModalViewModelTests: XCTestCase { await sut.fetchWallets() await sut.createURI() - XCTAssertEqual(sut.uri, "wc:foo@2?symKey=bar&relay-protocol=irn") + XCTAssertEqual(sut.uri, "wc:foo@2?symKey=bar&relay-protocol=irn&expiryTimestamp=1706001526") XCTAssertEqual(sut.wallets.count, 2) XCTAssertEqual(sut.wallets.map(\.id), ["1", "2"]) XCTAssertEqual(sut.wallets.map(\.name), ["Sample App", "Awesome App"]) @@ -94,7 +94,7 @@ final class ModalViewModelTests: XCTestCase { XCTAssertEqual( openURLFuncTest.currentValue, - URL(string: "https://example.com/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + URL(string: "https://example.com/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) expectation = XCTestExpectation(description: "Wait for openUrl to be called using universal link") @@ -104,7 +104,7 @@ final class ModalViewModelTests: XCTestCase { XCTAssertEqual( openURLFuncTest.currentValue, - URL(string: "awesomeapp://deeplinkwc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + URL(string: "awesomeapp://deeplinkwc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") @@ -114,7 +114,7 @@ final class ModalViewModelTests: XCTestCase { XCTAssertEqual( openURLFuncTest.currentValue, - URL(string: "https://awesome.com/awesome/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + URL(string: "https://awesome.com/awesome/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) expectation = XCTestExpectation(description: "Wait for openUrl to be called using native link") @@ -124,7 +124,7 @@ final class ModalViewModelTests: XCTestCase { XCTAssertEqual( openURLFuncTest.currentValue, - URL(string: "https://awesome.com/awesome/desktop/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn")! + URL(string: "https://awesome.com/awesome/desktop/universal/wc?uri=wc%3Afoo%402%3FsymKey%3Dbar%26relay-protocol%3Dirn%26expiryTimestamp%3D1706001526")! ) } } diff --git a/Tests/WalletConnectPairingTests/AppPairActivationServiceTests.swift b/Tests/WalletConnectPairingTests/AppPairActivationServiceTests.swift index 9cfd39b60..601862ad6 100644 --- a/Tests/WalletConnectPairingTests/AppPairActivationServiceTests.swift +++ b/Tests/WalletConnectPairingTests/AppPairActivationServiceTests.swift @@ -22,8 +22,8 @@ final class AppPairActivationServiceTests: XCTestCase { } func testActivate() { - let topic = "topic" - let pairing = WCPairing(topic: topic) + let pairing = WCPairing(uri: WalletConnectURI.stub()) + let topic = pairing.topic let date = pairing.expiryDate storageMock.setPairing(pairing) diff --git a/Tests/WalletConnectPairingTests/WCPairingTests.swift b/Tests/WalletConnectPairingTests/WCPairingTests.swift index f180efee2..8565b5bfa 100644 --- a/Tests/WalletConnectPairingTests/WCPairingTests.swift +++ b/Tests/WalletConnectPairingTests/WCPairingTests.swift @@ -23,21 +23,21 @@ final class WCPairingTests: XCTestCase { } func testInitInactiveFromTopic() { - let pairing = WCPairing(topic: "") + let pairing = WCPairing(uri: WalletConnectURI.stub()) let inactiveExpiry = referenceDate.advanced(by: WCPairing.timeToLiveInactive) XCTAssertFalse(pairing.active) - XCTAssertEqual(pairing.expiryDate, inactiveExpiry) + XCTAssertEqual(pairing.expiryDate.timeIntervalSince1970, inactiveExpiry.timeIntervalSince1970, accuracy: 1) } func testInitInactiveFromURI() { let pairing = WCPairing(uri: WalletConnectURI.stub()) let inactiveExpiry = referenceDate.advanced(by: WCPairing.timeToLiveInactive) XCTAssertFalse(pairing.active) - XCTAssertEqual(pairing.expiryDate, inactiveExpiry) + XCTAssertEqual(pairing.expiryDate.timeIntervalSince1970, inactiveExpiry.timeIntervalSince1970, accuracy: 1) } func testUpdateExpiryForTopic() { - var pairing = WCPairing(topic: "") + var pairing = WCPairing(uri: WalletConnectURI.stub()) let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) try? pairing.updateExpiry() XCTAssertEqual(pairing.expiryDate, activeExpiry) @@ -51,7 +51,7 @@ final class WCPairingTests: XCTestCase { } func testActivateTopic() { - var pairing = WCPairing(topic: "") + var pairing = WCPairing(uri: WalletConnectURI.stub()) let activeExpiry = referenceDate.advanced(by: WCPairing.timeToLiveActive) XCTAssertFalse(pairing.active) pairing.activate() diff --git a/Tests/WalletConnectSignTests/AppProposalServiceTests.swift b/Tests/WalletConnectSignTests/AppProposalServiceTests.swift index bdc8c7180..5e4b7da61 100644 --- a/Tests/WalletConnectSignTests/AppProposalServiceTests.swift +++ b/Tests/WalletConnectSignTests/AppProposalServiceTests.swift @@ -5,7 +5,7 @@ import JSONRPC @testable import TestingUtils @testable import WalletConnectKMS @testable import WalletConnectPairing -import WalletConnectUtils +@testable import WalletConnectUtils func deriveTopic(publicKey: String, privateKey: AgreementPrivateKey) -> String { try! KeyManagementService.generateAgreementKey(from: privateKey, peerPublicKey: publicKey).derivedTopic() @@ -59,6 +59,12 @@ final class AppProposalServiceTests: XCTestCase { kms: cryptoMock, logger: logger ) + let history = RPCHistory( + keyValueStore: .init( + defaults: RuntimeKeyValueStorage(), + identifier: "" + ) + ) approveEngine = ApproveEngine( networkingInteractor: networkingInteractor, proposalPayloadsStore: .init(defaults: RuntimeKeyValueStorage(), identifier: ""), @@ -70,7 +76,8 @@ final class AppProposalServiceTests: XCTestCase { logger: logger, pairingStore: storageMock, sessionStore: WCSessionStorageMock(), - verifyClient: VerifyClientMock() + verifyClient: VerifyClientMock(), + rpcHistory: history ) } diff --git a/Tests/WalletConnectSignTests/ApproveEngineTests.swift b/Tests/WalletConnectSignTests/ApproveEngineTests.swift index de84c86d2..c840d0ad0 100644 --- a/Tests/WalletConnectSignTests/ApproveEngineTests.swift +++ b/Tests/WalletConnectSignTests/ApproveEngineTests.swift @@ -1,12 +1,12 @@ import XCTest import Combine import JSONRPC -import WalletConnectUtils -import WalletConnectPairing import WalletConnectNetworking +@testable import WalletConnectPairing @testable import WalletConnectSign @testable import TestingUtils @testable import WalletConnectKMS +@testable import WalletConnectUtils final class ApproveEngineTests: XCTestCase { @@ -33,6 +33,12 @@ final class ApproveEngineTests: XCTestCase { proposalPayloadsStore = CodableStore>(defaults: RuntimeKeyValueStorage(), identifier: "") verifyContextStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "") sessionTopicToProposal = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "") + let history = RPCHistory( + keyValueStore: .init( + defaults: RuntimeKeyValueStorage(), + identifier: "" + ) + ) engine = ApproveEngine( networkingInteractor: networkingInteractor, proposalPayloadsStore: proposalPayloadsStore, @@ -44,7 +50,8 @@ final class ApproveEngineTests: XCTestCase { logger: ConsoleLoggerMock(), pairingStore: pairingStorageMock, sessionStore: sessionStorageMock, - verifyClient: VerifyClientMock() + verifyClient: VerifyClientMock(), + rpcHistory: history ) } @@ -73,6 +80,7 @@ final class ApproveEngineTests: XCTestCase { 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") + XCTAssertTrue(sessionStorageMock.hasSession(forTopic: topicB), "Responder must persist session on topic B") XCTAssertTrue(pairingRegisterer.isActivateCalled) } @@ -98,8 +106,7 @@ final class ApproveEngineTests: XCTestCase { let topicB = String.generateTopic() cryptoMock.setAgreementSecret(agreementKeys, topic: topicB) let proposal = SessionProposal.stub(proposerPubKey: AgreementPrivateKey().publicKey.hexRepresentation) - try await engine.settle(topic: topicB, proposal: proposal, namespaces: SessionNamespace.stubDictionary(), pairingTopic: "") - XCTAssertTrue(sessionStorageMock.hasSession(forTopic: topicB), "Responder must persist session on topic B") + _ = try await engine.settle(topic: topicB, proposal: proposal, namespaces: SessionNamespace.stubDictionary(), pairingTopic: "") XCTAssert(networkingInteractor.didSubscribe(to: topicB), "Responder must subscribe for topic B") XCTAssertTrue(networkingInteractor.didCallRequest, "Responder must send session settle payload on topic B") } diff --git a/Tests/WalletConnectSignTests/AutoNamespacesValidationTests.swift b/Tests/WalletConnectSignTests/AutoNamespacesValidationTests.swift index 1942c9d75..8e2d5260c 100644 --- a/Tests/WalletConnectSignTests/AutoNamespacesValidationTests.swift +++ b/Tests/WalletConnectSignTests/AutoNamespacesValidationTests.swift @@ -999,4 +999,28 @@ final class AutoNamespacesValidationTests: XCTestCase { ] XCTAssertEqual(sessionNamespaces, expectedNamespaces) } + + func testBuildThrowsWhenSessionNamespacesAreEmpty() { + let sessionProposal = Session.Proposal( + id: "", + pairingTopic: "", + proposer: AppMetadata(name: "", description: "", url: "", icons: [], redirect: AppMetadata.Redirect(native: "", universal: nil)), + requiredNamespaces: [:], + optionalNamespaces: [:], + sessionProperties: nil, + proposal: SessionProposal(relays: [], proposer: Participant(publicKey: "", metadata: AppMetadata(name: "", description: "", url: "", icons: [], redirect: AppMetadata.Redirect(native: "", universal: nil))), requiredNamespaces: [:], optionalNamespaces: [:], sessionProperties: [:]) + ) + + XCTAssertThrowsError(try AutoNamespaces.build( + sessionProposal: sessionProposal, + chains: [], + methods: [], + events: [], + accounts: [] + ), "Expected to throw AutoNamespacesError.emtySessionNamespacesForbidden, but it did not") { error in + guard case AutoNamespacesError.emtySessionNamespacesForbidden = error else { + return XCTFail("Unexpected error type: \(error)") + } + } + } } diff --git a/Tests/WalletConnectSignTests/SessionEngineTests.swift b/Tests/WalletConnectSignTests/SessionEngineTests.swift index 23f1e420b..c4c7a1112 100644 --- a/Tests/WalletConnectSignTests/SessionEngineTests.swift +++ b/Tests/WalletConnectSignTests/SessionEngineTests.swift @@ -7,14 +7,12 @@ final class SessionEngineTests: XCTestCase { var networkingInteractor: NetworkingInteractorMock! var sessionStorage: WCSessionStorageMock! - var proposalPayloadsStore: CodableStore>! var verifyContextStore: CodableStore! var engine: SessionEngine! override func setUp() { networkingInteractor = NetworkingInteractorMock() sessionStorage = WCSessionStorageMock() - proposalPayloadsStore = CodableStore>(defaults: RuntimeKeyValueStorage(), identifier: "") verifyContextStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "") engine = SessionEngine( networkingInteractor: networkingInteractor, @@ -25,7 +23,6 @@ final class SessionEngineTests: XCTestCase { identifier: "" ) ), - proposalPayloadsStore: proposalPayloadsStore, verifyContextStore: verifyContextStore ), verifyContextStore: verifyContextStore, diff --git a/Tests/WalletConnectSignTests/SessionProposalTests.swift b/Tests/WalletConnectSignTests/SessionProposalTests.swift new file mode 100644 index 000000000..e490ba5be --- /dev/null +++ b/Tests/WalletConnectSignTests/SessionProposalTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import WalletConnectSign + +class SessionProposalTests: XCTestCase { + + func testProposalNotExpiredImmediately() { + let proposal = SessionProposal.stub() + XCTAssertFalse(proposal.isExpired(), "Proposal should not be expired immediately after creation.") + } + + func testProposalExpired() { + let proposal = SessionProposal.stub() + let expiredDate = Date(timeIntervalSince1970: TimeInterval(proposal.expiryTimestamp! + 1)) + XCTAssertTrue(proposal.isExpired(currentDate: expiredDate), "Proposal should be expired after the expiry time.") + } + + func testProposalNotExpiredJustBeforeExpiry() { + let proposal = SessionProposal.stub() + let justBeforeExpiryDate = Date(timeIntervalSince1970: TimeInterval(proposal.expiryTimestamp! - 1)) + XCTAssertFalse(proposal.isExpired(currentDate: justBeforeExpiryDate), "Proposal should not be expired just before the expiry time.") + } + + // for backward compatibility + func testDecodingWithoutExpiry() throws { + let json = """ + { + "relays": [], + "proposer": { + "publicKey": "testKey", + "metadata": { + "name": "Wallet Connect", + "description": "A protocol to connect blockchain wallets to dapps.", + "url": "https://walletconnect.com/", + "icons": [] + } + }, + "requiredNamespaces": {}, + "optionalNamespaces": {}, + "sessionProperties": {} + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + let proposal = try decoder.decode(SessionProposal.self, from: json) + + // Assertions + XCTAssertNotNil(proposal, "Proposal should be successfully decoded even without an expiry field.") + XCTAssertNil(proposal.expiryTimestamp, "Expiry should be nil if not provided in JSON.") + } +} diff --git a/Tests/WalletConnectSignTests/SessionRequestTests.swift b/Tests/WalletConnectSignTests/SessionRequestTests.swift index e89ff347d..3321f2fe1 100644 --- a/Tests/WalletConnectSignTests/SessionRequestTests.swift +++ b/Tests/WalletConnectSignTests/SessionRequestTests.swift @@ -1,83 +1,71 @@ import XCTest @testable import WalletConnectSign -final class SessionRequestTests: XCTestCase { +class RequestTests: XCTestCase { - func testRequestTtlDefault() { - let request = Request.stub() - - XCTAssertEqual(request.calculateTtl(), SessionRequestProtocolMethod.defaultTtl) + func testInitWithValidTtl() { + XCTAssertNoThrow(try Request.stub(ttl: 3600)) // 1 hour } - func testRequestTtlExtended() { - let currentDate = Date(timeIntervalSince1970: 0) - let expiry = currentDate.advanced(by: 500) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - - XCTAssertEqual(request.calculateTtl(currentDate: currentDate), 500) + func testInitWithInvalidTtlTooShort() { + XCTAssertThrowsError(try Request.stub(ttl: 100)) { error in // Less than minTtl + XCTAssertEqual(error as? Request.Errors, Request.Errors.invalidTtl) + } } - func testRequestTtlNotExtendedMinValidation() { - let currentDate = Date(timeIntervalSince1970: 0) - let expiry = currentDate.advanced(by: 200) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - - XCTAssertEqual(request.calculateTtl(currentDate: currentDate), SessionRequestProtocolMethod.defaultTtl) + func testInitWithInvalidTtlTooLong() { + XCTAssertThrowsError(try Request.stub(ttl: 700000)) { error in // More than maxTtl + XCTAssertEqual(error as? Request.Errors, Request.Errors.invalidTtl) + } } - func testRequestTtlNotExtendedMaxValidation() { - let currentDate = Date(timeIntervalSince1970: 0) - let expiry = currentDate.advanced(by: 700000) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - - XCTAssertEqual(request.calculateTtl(currentDate: currentDate), SessionRequestProtocolMethod.defaultTtl) - } - - func testIsExpiredDefault() { - let request = Request.stub() - + func testIsExpiredForNonExpiredRequest() { + let request = try! Request.stub(ttl: 3600) // 1 hour XCTAssertFalse(request.isExpired()) } - func testIsExpiredTrue() { - let currentDate = Date(timeIntervalSince1970: 500) - let expiry = Date(timeIntervalSince1970: 0) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - XCTAssertTrue(request.isExpired(currentDate: currentDate)) + func testIsExpiredForExpiredRequest() { + let pastTimestamp = UInt64(Date().timeIntervalSince1970) - 3600 // 1 hour ago + let request = Request.stubWithExpiry(expiry: pastTimestamp) + XCTAssertTrue(request.isExpired()) } - func testIsExpiredTrueMinValidation() { - let currentDate = Date(timeIntervalSince1970: 500) - let expiry = Date(timeIntervalSince1970: 600) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - XCTAssertTrue(request.isExpired(currentDate: currentDate)) + func testCalculateTtlForNonExpiredRequest() { + let request = try! Request.stub(ttl: 3600) // 1 hour + XCTAssertNoThrow(try request.calculateTtl()) } - func testIsExpiredTrueMaxValidation() { - let currentDate = Date(timeIntervalSince1970: 500) - let expiry = Date(timeIntervalSince1970: 700000) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - XCTAssertTrue(request.isExpired(currentDate: currentDate)) + func testCalculateTtlForExpiredRequest() { + let pastTimestamp = UInt64(Date().timeIntervalSince1970) - 3600 // 1 hour ago + let request = Request.stubWithExpiry(expiry: pastTimestamp) + XCTAssertThrowsError(try request.calculateTtl()) { error in + XCTAssertEqual(error as? Request.Errors, Request.Errors.requestExpired) + } } +} - func testIsExpiredFalse() { - let currentDate = Date(timeIntervalSince1970: 0) - let expiry = Date(timeIntervalSince1970: 500) - let request = Request.stub(expiry: UInt64(expiry.timeIntervalSince1970)) - XCTAssertFalse(request.isExpired(currentDate: currentDate)) - } -} private extension Request { - static func stub(expiry: UInt64? = nil) -> Request { + static func stub(ttl: TimeInterval = 300) throws -> Request { + return try Request( + topic: "topic", + method: "method", + params: AnyCodable("params"), + chainId: Blockchain("eip155:1")!, + ttl: ttl + ) + } + + static func stubWithExpiry(expiry: UInt64) -> Request { return Request( + id: RPCID(JsonRpcID.generate()), topic: "topic", method: "method", params: AnyCodable("params"), chainId: Blockchain("eip155:1")!, - expiry: expiry + expiryTimestamp: expiry ) } } diff --git a/Tests/WalletConnectSignTests/Stub/Session+Stub.swift b/Tests/WalletConnectSignTests/Stub/Session+Stub.swift index 37fcfdef9..67bc7045d 100644 --- a/Tests/WalletConnectSignTests/Stub/Session+Stub.swift +++ b/Tests/WalletConnectSignTests/Stub/Session+Stub.swift @@ -31,7 +31,7 @@ extension WCSession { events: [], accounts: Account.stubSet(), acknowledged: acknowledged, - expiry: Int64(expiryDate.timeIntervalSince1970)) + expiryTimestamp: Int64(expiryDate.timeIntervalSince1970)) } } diff --git a/Tests/WalletConnectSignTests/Stub/Stubs.swift b/Tests/WalletConnectSignTests/Stub/Stubs.swift index 2f7961e22..9c5de2ccb 100644 --- a/Tests/WalletConnectSignTests/Stub/Stubs.swift +++ b/Tests/WalletConnectSignTests/Stub/Stubs.swift @@ -4,19 +4,7 @@ import JSONRPC import WalletConnectKMS import WalletConnectUtils import TestingUtils -import WalletConnectPairing - -extension Pairing { - static func stub(expiryDate: Date = Date(timeIntervalSinceNow: 10000), topic: String = String.generateTopic()) -> Pairing { - Pairing(topic: topic, peer: nil, expiryDate: expiryDate) - } -} - -extension WCPairing { - static func stub(expiryDate: Date = Date(timeIntervalSinceNow: 10000), isActive: Bool = true, topic: String = String.generateTopic()) -> WCPairing { - WCPairing(topic: topic, relay: RelayProtocolOptions.stub(), peerMetadata: AppMetadata.stub(), isActive: isActive, expiryDate: expiryDate) - } -} +@testable import WalletConnectPairing extension ProposalNamespace { static func stubDictionary() -> [String: ProposalNamespace] { @@ -68,7 +56,7 @@ extension RPCRequest { static func stubRequest(method: String, chainId: Blockchain, expiry: UInt64? = nil) -> RPCRequest { let params = SessionType.RequestParams( - request: SessionType.RequestParams.Request(method: method, params: AnyCodable(EmptyCodable()), expiry: expiry), + request: SessionType.RequestParams.Request(method: method, params: AnyCodable(EmptyCodable()), expiryTimestamp: expiry), chainId: chainId) return RPCRequest(method: SessionRequestProtocolMethod().method, params: params) } diff --git a/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift b/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift index 5f65b5c28..be9913382 100644 --- a/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift +++ b/Tests/WalletConnectUtilsTests/WalletConnectURITests.swift @@ -5,11 +5,11 @@ private func stubURI() -> (uri: WalletConnectURI, string: String) { let topic = Data.randomBytes(count: 32).toHexString() let symKey = Data.randomBytes(count: 32).toHexString() let protocolName = "irn" - let uriString = "wc:\(topic)@2?symKey=\(symKey)&relay-protocol=\(protocolName)" let uri = WalletConnectURI( topic: topic, symKey: symKey, relay: RelayProtocolOptions(protocol: protocolName, data: nil)) + let uriString = uri.absoluteString return (uri, uriString) } @@ -17,26 +17,26 @@ final class WalletConnectURITests: XCTestCase { // MARK: - Init URI with string - func testInitURIToString() { + func testInitURIToString() throws { let input = stubURI() let uriString = input.uri.absoluteString - let outputURI = WalletConnectURI(string: uriString) + let outputURI = try WalletConnectURI(uriString: uriString) XCTAssertEqual(input.uri, outputURI) - XCTAssertEqual(input.string, outputURI?.absoluteString) + XCTAssertEqual(input.string, outputURI.absoluteString) } - func testInitStringToURI() { + func testInitStringToURI() throws { let inputURIString = stubURI().string - let uri = WalletConnectURI(string: inputURIString) - let outputURIString = uri?.absoluteString + let uri = try WalletConnectURI(uriString: inputURIString) + let outputURIString = uri.absoluteString XCTAssertEqual(inputURIString, outputURIString) } - func testInitStringToURIAlternate() { + func testInitStringToURIAlternate() throws { let expectedString = stubURI().string let inputURIString = expectedString.replacingOccurrences(of: "wc:", with: "wc://") - let uri = WalletConnectURI(string: inputURIString) - let outputURIString = uri?.absoluteString + let uri = try WalletConnectURI(uriString: inputURIString) + let outputURIString = uri.absoluteString XCTAssertEqual(expectedString, outputURIString) } @@ -44,27 +44,63 @@ final class WalletConnectURITests: XCTestCase { func testInitFailsBadScheme() { let inputURIString = stubURI().string.replacingOccurrences(of: "wc:", with: "") - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) + XCTAssertThrowsError(try WalletConnectURI(uriString: inputURIString)) } func testInitFailsMalformedURL() { let inputURIString = "wc://<" - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) + XCTAssertThrowsError(try WalletConnectURI(uriString: inputURIString)) } func testInitFailsNoSymKeyParam() { let input = stubURI() let inputURIString = input.string.replacingOccurrences(of: "symKey=\(input.uri.symKey)", with: "") - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) + XCTAssertThrowsError(try WalletConnectURI(uriString: inputURIString)) } func testInitFailsNoRelayParam() { let input = stubURI() let inputURIString = input.string.replacingOccurrences(of: "&relay-protocol=\(input.uri.relay.protocol)", with: "") - let uri = WalletConnectURI(string: inputURIString) - XCTAssertNil(uri) + XCTAssertThrowsError(try WalletConnectURI(uriString: inputURIString)) } + + func testInitHandlesURLEncodedString() throws { + let input = stubURI() + let encodedURIString = input.string + .addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" + let uri = try WalletConnectURI(uriString: encodedURIString) + + // Assert that the initializer can handle encoded URI and it matches the expected URI + XCTAssertEqual(input.uri, uri) + XCTAssertEqual(input.string, uri.absoluteString) + } + + // MARK: - Expiry Logic Tests + + func testExpiryTimestampIsSet() { + let uri = stubURI().uri + XCTAssertNotNil(uri.expiryTimestamp) + XCTAssertTrue(uri.expiryTimestamp > UInt64(Date().timeIntervalSince1970)) + } + + func testInitFailsIfURIExpired() { + let input = stubURI() + // Create a URI string with an expired timestamp + let expiredTimestamp = UInt64(Date().timeIntervalSince1970) - 300 // 5 minutes in the past + let expiredURIString = "wc:\(input.uri.topic)@\(input.uri.version)?symKey=\(input.uri.symKey)&relay-protocol=\(input.uri.relay.protocol)&expiryTimestamp=\(expiredTimestamp)" + XCTAssertThrowsError(try WalletConnectURI(uriString: expiredURIString)) + } + + // Test compatibility with old clients that don't include expiryTimestamp in their uri + func testDefaultExpiryTimestampIfNotIncluded() throws { + let input = stubURI().string + // Remove expiryTimestamp from the URI string + let uriStringWithoutExpiry = input.replacingOccurrences(of: "&expiryTimestamp=\(stubURI().uri.expiryTimestamp)", with: "") + let uri = try WalletConnectURI(uriString: uriStringWithoutExpiry) + + // Check if the expiryTimestamp is set to 5 minutes in the future + let expectedExpiryTimestamp = UInt64(Date().timeIntervalSince1970) + 5 * 60 + XCTAssertTrue(uri.expiryTimestamp >= expectedExpiryTimestamp) + } + } diff --git a/Tests/Web3WalletTests/Mocks/PairingClientMock.swift b/Tests/Web3WalletTests/Mocks/PairingClientMock.swift index 0f0012d59..3e752da08 100644 --- a/Tests/Web3WalletTests/Mocks/PairingClientMock.swift +++ b/Tests/Web3WalletTests/Mocks/PairingClientMock.swift @@ -4,6 +4,17 @@ import Combine @testable import WalletConnectPairing final class PairingClientMock: PairingClientProtocol { + var pairingStatePublisher: AnyPublisher { + pairingStatePublisherSubject.eraseToAnyPublisher() + } + var pairingStatePublisherSubject = PassthroughSubject() + + var pairingExpirationPublisher: AnyPublisher { + return pairingExpirationPublisherSubject.eraseToAnyPublisher() + } + var pairingExpirationPublisherSubject = PassthroughSubject() + + var pairingDeletePublisher: AnyPublisher<(code: Int, message: String), Never> { pairingDeletePublisherSubject.eraseToAnyPublisher() } @@ -29,6 +40,6 @@ final class PairingClientMock: PairingClientProtocol { } func getPairings() -> [Pairing] { - return [Pairing(topic: "", peer: nil, expiryDate: Date())] + return [Pairing(WCPairing.stub())] } } diff --git a/Tests/Web3WalletTests/Mocks/SignClientMock.swift b/Tests/Web3WalletTests/Mocks/SignClientMock.swift index de4b3cb22..f144ef727 100644 --- a/Tests/Web3WalletTests/Mocks/SignClientMock.swift +++ b/Tests/Web3WalletTests/Mocks/SignClientMock.swift @@ -22,7 +22,7 @@ final class SignClientMock: SignClientProtocol { var requestCalled = false private let metadata = AppMetadata(name: "", description: "", url: "", icons: [], redirect: AppMetadata.Redirect(native: "", universal: nil)) - private let request = WalletConnectSign.Request(id: .left(""), topic: "", method: "", params: "", chainId: Blockchain("eip155:1")!, expiry: nil) + private let request = WalletConnectSign.Request(id: .left(""), topic: "", method: "", params: AnyCodable(""), chainId: Blockchain("eip155:1")!, expiryTimestamp: nil) private let response = WalletConnectSign.Response(id: RPCID(1234567890123456789), topic: "", chainId: "", result: .response(AnyCodable(any: ""))) var sessionProposalPublisher: AnyPublisher<(proposal: WalletConnectSign.Session.Proposal, context: VerifyContext?), Never> { @@ -63,7 +63,17 @@ final class SignClientMock: SignClientProtocol { return Result.Publisher(("topic", ReasonMock())) .eraseToAnyPublisher() } - + + var pendingProposalsPublisher: AnyPublisher<[(proposal: WalletConnectSign.Session.Proposal, context: WalletConnectSign.VerifyContext?)], Never> { + return Result.Publisher([]) + .eraseToAnyPublisher() + } + + var requestExpirationPublisher: AnyPublisher { + Result.Publisher(request) + .eraseToAnyPublisher() + } + var sessionEventPublisher: AnyPublisher<(event: WalletConnectSign.Session.Event, sessionTopic: String, chainId: WalletConnectUtils.Blockchain?), Never> { return Result.Publisher( ( @@ -74,7 +84,11 @@ final class SignClientMock: SignClientProtocol { ) .eraseToAnyPublisher() } - + + var sessionProposalExpirationPublisher: AnyPublisher { + fatalError() + } + var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { let sessionProposal = Session.Proposal( id: "", @@ -138,11 +152,7 @@ final class SignClientMock: SignClientProtocol { func getPendingRequests(topic: String?) -> [(request: WalletConnectSign.Request, context: WalletConnectSign.VerifyContext?)] { return [(request, nil)] } - - func getSessionRequestRecord(id: JSONRPC.RPCID) -> (request: WalletConnectSign.Request, context: WalletConnectSign.VerifyContext?)? { - return (request, nil) - } - + func cleanup() async throws { cleanupCalled = true } diff --git a/Tests/Web3WalletTests/Web3WalletTests.swift b/Tests/Web3WalletTests/Web3WalletTests.swift index 39165751d..520639d83 100644 --- a/Tests/Web3WalletTests/Web3WalletTests.swift +++ b/Tests/Web3WalletTests/Web3WalletTests.swift @@ -249,11 +249,6 @@ final class Web3WalletTests: XCTestCase { XCTAssertEqual(1, pendingRequests.count) } - func testSessionRequestRecordCalledAndNotNil() async { - let sessionRequestRecord = web3WalletClient.getSessionRequestRecord(id: .left("")) - XCTAssertNotNil(sessionRequestRecord) - } - func testAuthPendingRequestsCalledAndNotEmpty() async { let pendingRequests = try! web3WalletClient.getPendingRequests() XCTAssertEqual(1, pendingRequests.count)