diff --git a/Example/metamask-ios-sdk.xcodeproj/project.pbxproj b/Example/metamask-ios-sdk.xcodeproj/project.pbxproj index 78b1328..dbb96be 100644 --- a/Example/metamask-ios-sdk.xcodeproj/project.pbxproj +++ b/Example/metamask-ios-sdk.xcodeproj/project.pbxproj @@ -21,6 +21,9 @@ D13A7535296E7EA0005EE461 /* ButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D13A7534296E7EA0005EE461 /* ButtonStyle.swift */; }; D148AC75292FCF1B001791E5 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D148AC74292FCF1B001791E5 /* ConnectView.swift */; }; D1494FEB2970149B002D36D6 /* SwitchChainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1494FEA2970149B002D36D6 /* SwitchChainView.swift */; }; + D14FA2F429E576EB00F3A059 /* ToastOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14FA2F329E576EB00F3A059 /* ToastOverlay.swift */; }; + D14FA2F629E578B200F3A059 /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14FA2F529E578B200F3A059 /* ViewExtension.swift */; }; + D14FA2F829E57B6900F3A059 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D14FA2F729E57B6900F3A059 /* ToastView.swift */; }; D1896062296C3F9500216307 /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1896061296C3F9500216307 /* NetworkView.swift */; }; D1EA931D29538C570078F088 /* TextStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EA931C29538C570078F088 /* TextStyle.swift */; }; /* End PBXBuildFile section */ @@ -57,6 +60,9 @@ D13A7534296E7EA0005EE461 /* ButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyle.swift; sourceTree = ""; }; D148AC74292FCF1B001791E5 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; D1494FEA2970149B002D36D6 /* SwitchChainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchChainView.swift; sourceTree = ""; }; + D14FA2F329E576EB00F3A059 /* ToastOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastOverlay.swift; sourceTree = ""; }; + D14FA2F529E578B200F3A059 /* ViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtension.swift; sourceTree = ""; }; + D14FA2F729E57B6900F3A059 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D1896061296C3F9500216307 /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = ""; }; D1EA931C29538C570078F088 /* TextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextStyle.swift; sourceTree = ""; }; D5694CDE7AD78A1D52AC0870 /* Pods-metamask-ios-sdk_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-metamask-ios-sdk_Tests.debug.xcconfig"; path = "Target Support Files/Pods-metamask-ios-sdk_Tests/Pods-metamask-ios-sdk_Tests.debug.xcconfig"; sourceTree = ""; }; @@ -184,6 +190,9 @@ D107588E293F385700D6C66B /* TransactionView.swift */, D1896061296C3F9500216307 /* NetworkView.swift */, D1494FEA2970149B002D36D6 /* SwitchChainView.swift */, + D14FA2F329E576EB00F3A059 /* ToastOverlay.swift */, + D14FA2F529E578B200F3A059 /* ViewExtension.swift */, + D14FA2F729E57B6900F3A059 /* ToastView.swift */, ); name = Views; sourceTree = ""; @@ -380,7 +389,10 @@ D107588F293F385700D6C66B /* TransactionView.swift in Sources */, D1EA931D29538C570078F088 /* TextStyle.swift in Sources */, D11D2F1B295075A3000E8003 /* Curvature.swift in Sources */, + D14FA2F629E578B200F3A059 /* ViewExtension.swift in Sources */, + D14FA2F829E57B6900F3A059 /* ToastView.swift in Sources */, D1494FEB2970149B002D36D6 /* SwitchChainView.swift in Sources */, + D14FA2F429E576EB00F3A059 /* ToastOverlay.swift in Sources */, 607FACD81AFB9204008FA782 /* ViewController.swift in Sources */, D13A7535296E7EA0005EE461 /* ButtonStyle.swift in Sources */, 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */, @@ -594,6 +606,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5694CDE7AD78A1D52AC0870 /* Pods-metamask-ios-sdk_Tests.debug.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( @@ -620,6 +633,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 780C3F18921DAF52E227C55E /* Pods-metamask-ios-sdk_Tests.release.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( diff --git a/Example/metamask-ios-sdk/ConnectView.swift b/Example/metamask-ios-sdk/ConnectView.swift index b68385c..3c086d4 100644 --- a/Example/metamask-ios-sdk/ConnectView.swift +++ b/Example/metamask-ios-sdk/ConnectView.swift @@ -25,6 +25,7 @@ struct ConnectView: View { @State private var showError = false @State private var showProgressView = false + @State private var showToast = false var body: some View { NavigationView { @@ -74,30 +75,23 @@ struct ConnectView: View { NavigationLink("Switch chain") { SwitchChainView() } + + Button { + ethereum.clearSession() + showToast = true + } label: { + Text("Clear Session") + .modifier(TextButton()) + .frame(maxWidth: .infinity, maxHeight: 32) + } + .toast(isPresented: $showToast) { + ToastView(message: "Session cleared") + } + .modifier(ButtonStyle()) } } } - /* Hide this until changing network url is fully supported by MM - if ethereum.selectedAddress.isEmpty { - Section { - // Silly ZStack hack to hide disclosure indicator on NavigationLink - ZStack() { - NavigationLink { - NetworkView() - } label: { - Text(" ") - } - .opacity(0) - Text("Change network url") - .modifier(TextButton()) - .frame(maxWidth: .infinity, maxHeight: 32) - .modifier(ButtonStyle()) - } - } - } - */ - if ethereum.selectedAddress.isEmpty { Section { ZStack { diff --git a/Example/metamask-ios-sdk/ToastOverlay.swift b/Example/metamask-ios-sdk/ToastOverlay.swift new file mode 100644 index 0000000..3ac468c --- /dev/null +++ b/Example/metamask-ios-sdk/ToastOverlay.swift @@ -0,0 +1,35 @@ +// +// ToastOverlay.swift +// metamask-ios-sdk_Example +// + +import SwiftUI + +struct ToastOverlay: View where ToastContent : View { + let content: ToastContent + @Binding var isPresented: Bool + + + var body: some View { + GeometryReader { geometry in + VStack { + Spacer() + HStack { + Spacer() + content + .frame(width: geometry.size.width * 0.8, height: 8) + .animation(.easeIn) + Spacer() + } + Spacer() + } + } + .background(Color.clear) + .edgesIgnoringSafeArea(.bottom) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + isPresented = false + } + } + } +} diff --git a/Example/metamask-ios-sdk/ToastView.swift b/Example/metamask-ios-sdk/ToastView.swift new file mode 100644 index 0000000..79b384b --- /dev/null +++ b/Example/metamask-ios-sdk/ToastView.swift @@ -0,0 +1,26 @@ +// +// ToastView.swift +// metamask-ios-sdk_Example +// + +import SwiftUI + +struct ToastView: View { + let message: String + + var body: some View { + VStack { + Text(message) + .padding() + .foregroundColor(.white) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) + } + } +} + +struct ToastView_Previews: PreviewProvider { + static var previews: some View { + ToastView(message: "Test message") + } +} diff --git a/Example/metamask-ios-sdk/ViewExtension.swift b/Example/metamask-ios-sdk/ViewExtension.swift new file mode 100644 index 0000000..934b701 --- /dev/null +++ b/Example/metamask-ios-sdk/ViewExtension.swift @@ -0,0 +1,17 @@ +// +// ViewExtension.swift +// metamask-ios-sdk_Example +// + +import SwiftUI + +extension View { + func toast(isPresented: Binding, @ViewBuilder content: () -> ToastContent) -> some View { + ZStack { + self + if isPresented.wrappedValue { + ToastOverlay(content: content(), isPresented: isPresented) + } + } + } +} diff --git a/README.md b/README.md index 2249cda..a8f36e2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ To add MetaMask iOS SDK as an SPM package to your project, in Xcode select: `Fil dependencies: [ .package( url: "https://github.com/MetaMask/metamask-ios-sdk", - from: "0.1.0" + from: "0.2.0" ) ] ``` diff --git a/Sources/metamask-ios-sdk/Classes/Communication/SocketChannel.swift b/Sources/metamask-ios-sdk/Classes/Communication/SocketChannel.swift index b53866e..ea17cf2 100644 --- a/Sources/metamask-ios-sdk/Classes/Communication/SocketChannel.swift +++ b/Sources/metamask-ios-sdk/Classes/Communication/SocketChannel.swift @@ -17,6 +17,10 @@ class SocketChannel { var socket: SocketIOClient! private var socketManager: SocketManager! + + var isConnected: Bool { + socket.status == .connected + } init() { configureSocket(url: Endpoint.SERVER_URL) @@ -55,6 +59,10 @@ extension SocketChannel { func disconnect() { socket.disconnect() } + + func terminateHandlers() { + socket.removeAllHandlers() + } } // MARK: Events diff --git a/Sources/metamask-ios-sdk/Classes/Communication/SocketClient.swift b/Sources/metamask-ios-sdk/Classes/Communication/SocketClient.swift index f82f095..f80de72 100644 --- a/Sources/metamask-ios-sdk/Classes/Communication/SocketClient.swift +++ b/Sources/metamask-ios-sdk/Classes/Communication/SocketClient.swift @@ -8,36 +8,53 @@ import Combine import SocketIO import Foundation +typealias RequestJob = () -> Void + protocol CommunicationClient: AnyObject { var clientName: String { get } var dapp: Dapp? { get set } + var deeplinkUrl: String { get } var isConnected: Bool { get set } var serverUrl: String { get set } + var hasValidSession: Bool { get } + var sessionDuration: TimeInterval { get set } - var onClientsReady: (() -> Void)? { get set } var tearDownConnection: (() -> Void)? { get set } + var onClientsTerminated: (() -> Void)? { get set } var receiveEvent: (([String: Any]) -> Void)? { get set } var receiveResponse: ((String, [String: Any]) -> Void)? { get set } func connect() func disconnect() + func clearSession() func enableTracking(_ enable: Bool) + func addRequest(_ job: @escaping RequestJob) func sendMessage(_ message: T, encrypt: Bool) } class SocketClient: CommunicationClient { var dapp: Dapp? private var tracker: Tracking + private let store: SecureStore private var keyExchange = KeyExchange() private let channel = SocketChannel() - private var channelId: String = UUID().uuidString + private var channelId: String = "" + private var connectionPaused: Bool = false - private var restartedConnection = false + + private let SESSION_KEY = "session_id" var clientName: String { "socket" } + + // 7 days default, configurable + var sessionDuration: TimeInterval = 24 * 3600 * 7 { + didSet { + updateSessionConfig() + } + } var serverUrl: String { get { @@ -46,14 +63,19 @@ class SocketClient: CommunicationClient { channel.serverUrl = newValue } } + + var hasValidSession: Bool { + sessionConfig?.isValid ?? false + } var isConnected: Bool = false - var onClientsReady: (() -> Void)? var tearDownConnection: (() -> Void)? - var onClientsDisconnected: (() -> Void)? + var onClientsTerminated: (() -> Void)? var receiveEvent: (([String: Any]) -> Void)? var receiveResponse: ((String, [String: Any]) -> Void)? + + var requestJobs: [RequestJob] = [] var deeplinkUrl: String { "https://metamask.app.link/connect?channelId=" @@ -62,24 +84,31 @@ class SocketClient: CommunicationClient { + "&pubkey=" + keyExchange.pubkey } + + private var sessionConfig: SessionConfig? - init(tracker: Tracking) { + init(store: SecureStore, tracker: Tracking) { + self.store = store self.tracker = tracker - setupClient() } - - private func setupClient() { + + func setupClient() { + configureSession() handleReceiveMessages() handleConnection() handleDisconnection() } - private func resetClient() { + func resetClient() { isConnected = false + self.keyExchange.reset() tearDownConnection?() } func connect() { + guard !channel.isConnected else { return } + + setupClient() trackEvent(.connectionRequest) channel.connect() } @@ -87,6 +116,57 @@ class SocketClient: CommunicationClient { func disconnect() { isConnected = false channel.disconnect() + channel.terminateHandlers() + } + + private func fetchSessionConfig() -> SessionConfig? { + let config: SessionConfig? = store.model(for: SESSION_KEY) + return config + } + + private func configureSession() { + if let config = fetchSessionConfig(), config.isValid { + channelId = config.sessionId + } else { + // purge any existing session info + store.deleteData(for: SESSION_KEY) + channelId = UUID().uuidString + } + updateSessionConfig() + } + + func clearSession() { + store.deleteData(for: SESSION_KEY) + resetClient() + setupClient() + } + + private func updateSessionConfig() { + // update session expiry date + let config = SessionConfig(sessionId: channelId, + expiry: Date(timeIntervalSinceNow: sessionDuration)) + + sessionConfig = config + + // persist session config + if let configData = try? JSONEncoder().encode(config) { + store.save(data: configData, key: SESSION_KEY) + } + } +} + +// MARK: Request jobs + +extension SocketClient { + func addRequest(_ job: @escaping RequestJob) { + requestJobs.append(job) + } + + func runJobs() { + while !requestJobs.isEmpty { + let job = requestJobs.popLast() + job?() + } } } @@ -116,11 +196,6 @@ private extension SocketClient { object: nil, userInfo: ["value": "Clients Connected"] ) - - if !self.keyExchange.keysExchanged { - let keyExchangeSync = self.keyExchange.message(type: .syn) - self.sendMessage(keyExchangeSync, encrypt: false) - } } // MARK: Socket connected event @@ -154,6 +229,12 @@ private extension SocketClient { let message = data.first as? [String: Any] else { return } + if + let message = message["message"] as? [String: Any], + message["type"] as? String == KeyExchangeType.start.rawValue { + self.keyExchange.reset() + } + if !self.keyExchange.keysExchanged { // Exchange keys self.handleReceiveKeyExchange(message) @@ -182,6 +263,7 @@ private extension SocketClient { if !self.connectionPaused { self.resetClient() + self.connectionPaused = true } } } @@ -203,30 +285,16 @@ private extension SocketClient { } } - func handleMessage(_ message: [String: Any]) { - if - KeyExchange.isHandshakeRestartMessage(message) { - keyExchange.restart() - isConnected = true - connectionPaused = false - restartedConnection = true - - if - let keyExchangeMessage = Message.message(from: message), - let nextKeyExchangeMessage = keyExchange.nextMessage(keyExchangeMessage.message) { - sendMessage(nextKeyExchangeMessage, encrypt: false) - } - } else { - guard let message = Message.message(from: message) else { - Logging.error("Could not handle message") - return - } + func handleMessage(_ msg: [String: Any]) { + guard let message = Message.message(from: msg) else { + Logging.error("Could not parse message \(msg)") + return + } - do { - try handleEncryptedMessage(message) - } catch { - Logging.error(error.localizedDescription) - } + do { + try handleEncryptedMessage(message) + } catch { + Logging.error(error.localizedDescription) } } @@ -239,17 +307,21 @@ private extension SocketClient { ) as? [String: Any] ?? [:] - if json["type"] as? String == "pause" { + if json["type"] as? String == "terminate" { + disconnect() + onClientsTerminated?() + Logging.log("Connection terminated") + } else if json["type"] as? String == "pause" { Logging.log("Connection has been paused") connectionPaused = true } else if json["type"] as? String == "ready" { Logging.log("Connection is ready") + isConnected = true connectionPaused = false - onClientsReady?() + runJobs() } else if json["type"] as? String == "wallet_info" { Logging.log("Received wallet info") isConnected = true - onClientsReady?() connectionPaused = false } else if let data = json["data"] as? [String: Any] { if let id = data["id"] as? String { @@ -283,7 +355,8 @@ extension SocketClient { let originatorInfo = OriginatorInfo( title: dapp?.name, url: dapp?.url, - platform: SDKInfo.platform + platform: SDKInfo.platform, + apiVersion: SDKInfo.version ) let requestInfo = RequestInfo( @@ -297,46 +370,60 @@ extension SocketClient { func sendMessage(_ message: T, encrypt: Bool) { if encrypt && !keyExchange.keysExchanged { - Logging.error("Attempting to send encrypted message without exchanging encryption keys") - return - } - - if encrypt { - do { - var encryptedMessage: String = try keyExchange.encryptMessage(message) - - if connectionPaused { - Logging.log("Connection paused. Will send once wallet is open again") - onClientsReady = { [weak self] in - guard let self = self else { return } - Logging.log("Resuming sending requests") - - if self.restartedConnection { - // their public key has changed, encrypt message again - do { - encryptedMessage = try self.keyExchange.encryptMessage(message) - } catch { - Logging.error("\(error.localizedDescription)") - } - self.restartedConnection = false - } + addRequest { [weak self] in + guard let self = self else { return } + Logging.log("Resuming sending requests after reconnection") + + do { + let encryptedMessage: String = try self.keyExchange.encryptMessage(message) + // debug code + let data = try! JSONEncoder().encode(message) + let message: Message = .init( + id: self.channelId, + message: encryptedMessage + ) + self.channel.emit(ClientEvent.message, message) + } catch { + Logging.error("Could not encrypt message") + } + } + } else if encrypt { + if connectionPaused { + Logging.log("Connection paused. Will send once wallet is open again") + addRequest { [weak self] in + guard let self = self else { return } + Logging.log("Resuming sending requests after connection pause") + + do { + let encryptedMessage: String = try self.keyExchange.encryptMessage(message) + // debug code + let data = try! JSONEncoder().encode(message) let message: Message = .init( id: self.channelId, message: encryptedMessage ) self.channel.emit(ClientEvent.message, message) + + } catch { + Logging.error("\(error.localizedDescription)") } - } else { + } + } else { + do { + let encryptedMessage: String = try self.keyExchange.encryptMessage(message) + let data = try! JSONEncoder().encode(message) let message: Message = .init( id: channelId, message: encryptedMessage ) channel.emit(ClientEvent.message, message) + + } catch { + Logging.error("\(error.localizedDescription)") } - } catch { - Logging.error("\(error.localizedDescription)") } } else { + let data = try! JSONEncoder().encode(message) let message = Message( id: channelId, message: message diff --git a/Sources/metamask-ios-sdk/Classes/Communication/models/Models.swift b/Sources/metamask-ios-sdk/Classes/Communication/models/Models.swift index f311ac1..7097ec1 100644 --- a/Sources/metamask-ios-sdk/Classes/Communication/models/Models.swift +++ b/Sources/metamask-ios-sdk/Classes/Communication/models/Models.swift @@ -13,12 +13,14 @@ struct OriginatorInfo: CodableData { let title: String? let url: String? let platform: String? + let apiVersion: String? func socketRepresentation() -> NetworkData { [ "title": title, "url": url, - "platform": platform + "platform": platform, + "apiVersion": apiVersion, ] } } @@ -30,7 +32,7 @@ struct Message: CodableData { func socketRepresentation() -> NetworkData { [ "id": id, - "message": try? message.socketRepresentation() + "message": try? message.socketRepresentation(), ] } diff --git a/Sources/metamask-ios-sdk/Classes/Crypto/KeyExchange.swift b/Sources/metamask-ios-sdk/Classes/Crypto/KeyExchange.swift index c236f5d..7a5170a 100644 --- a/Sources/metamask-ios-sdk/Classes/Crypto/KeyExchange.swift +++ b/Sources/metamask-ios-sdk/Classes/Crypto/KeyExchange.swift @@ -46,9 +46,9 @@ public struct KeyExchangeMessage: CodableData { */ public class KeyExchange { - private let privateKey: String + private var privateKey: String - public let pubkey: String + public var pubkey: String public private(set) var theirPublicKey: String? private let encyption: Crypto.Type @@ -60,17 +60,17 @@ public class KeyExchange { pubkey = encyption.publicKey(from: privateKey) } - func restart() { + func reset() { keysExchanged = false + theirPublicKey = nil + privateKey = encyption.generatePrivateKey() + pubkey = encyption.publicKey(from: privateKey) } func nextMessage(_ message: KeyExchangeMessage) -> KeyExchangeMessage? { - if message.type == .synack || message.type == .ack { - keysExchanged = true - } - if let publicKey = message.pubkey { setTheirPublicKey(publicKey) + keysExchanged = true } guard let nextStep = nextStep(message.type) else { diff --git a/Sources/metamask-ios-sdk/Classes/Ethereum/ErrorType.swift b/Sources/metamask-ios-sdk/Classes/Ethereum/ErrorType.swift index 4920cdf..c8b7330 100644 --- a/Sources/metamask-ios-sdk/Classes/Ethereum/ErrorType.swift +++ b/Sources/metamask-ios-sdk/Classes/Ethereum/ErrorType.swift @@ -28,25 +28,25 @@ public enum ErrorType: Int { var message: String { switch self { case .userRejectedRequest: - return "Ethereum Provider User Rejected Request" + return "User rejected the request" case .unauthorisedRequest: - return "Ethereum Provider User Rejected Request" + return "User rejected the request" case .unsupportedMethod: - return "Ethereum Provider Unsupported Method" + return "Unsupported method" case .disconnected: - return "Ethereum Provider Not Connected" + return "Not connected" case .chainDisconnected: - return "Ethereum Provider Chain Not Connected" + return "Chain not connected" case .unrecognizedChainId: return "Unrecognized chain ID. Try adding the chain using addEthereumChain first" case .invalidInput: - return "JSON RPC 2.0 Server error" + return "JSON RPC server error" case .transactionRejected: - return "Ethereum Transaction Rejected" + return "Transaction rejected" case .invalidRequest: - return "Invalid Request" + return "Invalid request" case .invalidMethodParameters: - return "Invalid Method Parameters" + return "Invalid method parameters" case .serverError: return "Server error" case .parseError: diff --git a/Sources/metamask-ios-sdk/Classes/Ethereum/Ethereum.swift b/Sources/metamask-ios-sdk/Classes/Ethereum/Ethereum.swift index a47fa79..011f7ba 100644 --- a/Sources/metamask-ios-sdk/Classes/Ethereum/Ethereum.swift +++ b/Sources/metamask-ios-sdk/Classes/Ethereum/Ethereum.swift @@ -71,6 +71,20 @@ public extension Ethereum { /// Disconnect dapp func disconnect() { connected = false + delegate?.disconnect() + } + + func clearSession() { + delegate?.clearSession() + } + + func terminateConnection() { + let error = RequestError(from: ["message": "The connection request has been rejected"]) + submittedRequests.forEach { key, value in + submittedRequests[key]?.error(error) + } + submittedRequests.removeAll() + disconnect() } } @@ -96,10 +110,11 @@ extension Ethereum { var request = request request.id = id delegate?.sendMessage(request, encrypt: true) - + if openDeeplink, - let url = URL(string: "https://metamask.app.link") { + let deeplink = delegate?.deeplinkUrl, + let url = URL(string: deeplink) { DispatchQueue.main.async { UIApplication.shared.open(url) } @@ -131,30 +146,42 @@ extension Ethereum { submittedRequests[CONNECTION_ID] = submittedRequest publisher = submittedRequests[CONNECTION_ID]?.publisher - delegate?.onClientsReady = requestAccounts + delegate?.addRequest(requestAccounts) } else { let id = UUID().uuidString let submittedRequest = SubmittedRequest(method: request.method) submittedRequests[id] = submittedRequest publisher = submittedRequests[id]?.publisher - - if let method = EthereumMethod(rawValue: request.method) { - sendRequest( - request, - id: id, - openDeeplink: connected ? shouldOpenMetaMask(method: method) : true - ) + + if !connected { + delegate?.connect() + delegate?.addRequest { + self.makeRequest(request, id: id) + } } else { - sendRequest( - request, - id: id, - openDeeplink: connected ? false : true - ) + makeRequest(request, id: id) } + } return publisher } + + private func makeRequest(_ request: EthereumRequest, id: String) { + if let method = EthereumMethod(rawValue: request.method) { + sendRequest( + request, + id: id, + openDeeplink: connected ? shouldOpenMetaMask(method: method) : true + ) + } else { + sendRequest( + request, + id: id, + openDeeplink: connected ? false : true + ) + } + } } // MARK: Request Receiving @@ -167,13 +194,23 @@ extension Ethereum { private func updateAccount(_ account: String) { selectedAddress = account } + + func sendResult(_ result: Any, id: String) { + submittedRequests[id]?.send(result) + submittedRequests.removeValue(forKey: id) + } + + func sendError(_ error: RequestError, id: String) { + submittedRequests[id]?.error(error) + submittedRequests.removeValue(forKey: id) + } func receiveResponse(id: String, data: [String: Any]) { guard let request = submittedRequests[id] else { return } if let error = data["error"] as? [String: Any] { - let RequestError = RequestError(from: error) - submittedRequests[id]?.error(RequestError) + let requestError = RequestError(from: error) + sendError(requestError, id: id) return } @@ -181,9 +218,9 @@ extension Ethereum { let method = EthereumMethod(rawValue: request.method), EthereumMethod.isResultMethod(method) else { if let result = data["result"] { - submittedRequests[id]?.send(result) + sendResult(result, id: id) } else { - submittedRequests[id]?.send(data) + sendResult(data, id: id) } return } @@ -195,37 +232,37 @@ extension Ethereum { if let account = accounts.first { updateAccount(account) - submittedRequests[id]?.send(account) + sendResult(account, id: id) } if let chainId = result["chainId"] as? String { updateChainId(chainId) - submittedRequests[id]?.send(chainId) + sendResult(chainId, id: id) } case .ethRequestAccounts: let result: [String] = data["result"] as? [String] ?? [] if let account = result.first { updateAccount(account) - submittedRequests[id]?.send(account) + sendResult(account, id: id) } else { Logging.error("Request accounts failure") } case .ethChainId: if let result: String = data["result"] as? String { updateChainId(result) - submittedRequests[id]?.send(result) + sendResult(result, id: id) } case .ethSignTypedDataV4, .ethSignTypedDataV3, .ethSendTransaction: if let result: String = data["result"] as? String { - submittedRequests[id]?.send(result) + sendResult(result, id: id) } else { Logging.error("Unexpected response \(data)") } default: if let result = data["result"] { - submittedRequests[id]?.send(result) + sendResult(result, id: id) } else { Logging.error("Unknown response: \(data)") } @@ -254,7 +291,7 @@ extension Ethereum { updateChainId(chainId) } default: - break + Logging.error("Unhandled case: \(event)") } } } diff --git a/Sources/metamask-ios-sdk/Classes/Persistence/SecureStore.swift b/Sources/metamask-ios-sdk/Classes/Persistence/SecureStore.swift new file mode 100644 index 0000000..64c65a1 --- /dev/null +++ b/Sources/metamask-ios-sdk/Classes/Persistence/SecureStore.swift @@ -0,0 +1,116 @@ +// +// SecureStore.swift +// metamask-ios-sdk +// +import Foundation + +public protocol SecureStore { + func string(for key: String) -> String? + + @discardableResult + func deleteData(for key: String) -> Bool + + @discardableResult + func save(string: String, key: String) -> Bool + + @discardableResult + func save(data: Data, key: String) -> Bool + + func model(for key: String) -> T? +} + +public struct Keychain: SecureStore { + private let service: String + + public init(service: String) { + self.service = service + } + + public func string(for key: String) -> String? { + guard + let data = data(for: key), + let string = String(data: data, encoding: .utf8) + else { return nil } + return string + } + + public func deleteData(for key: String) -> Bool { + let request = deletionRequestForKey(key) + let status: OSStatus = SecItemDelete(request) + return status == errSecSuccess + } + + public func save(string: String, key: String) -> Bool { + guard let data = string.data(using: .utf8) else { return false } + return save(data: data, key: key) + } + + @discardableResult + public func save(data: Data, key: String) -> Bool { + guard let attributes = attributes(for: data, key: key) else { return false } + + let status: OSStatus = SecItemAdd(attributes, nil) + + switch status { + case noErr: + return true + case errSecDuplicateItem: + guard deleteData(for: key) else { return false } + return save(data: data, key: key) + default: + return false + } + } + + public func model(for key: String) -> T? { + guard + let data = data(for: key), + let model = try? JSONDecoder().decode(T.self, from: data) + else { return nil } + + return model + } + + // MARK: Helper functions + + func data(for key: String) -> Data? { + let request = requestForKey(key) + var dataTypeRef: CFTypeRef? + let status: OSStatus = SecItemCopyMatching(request, &dataTypeRef) + + switch status { + case errSecSuccess: + return dataTypeRef as? Data + default: + return nil + } + } + + private func requestForKey(_ key: String) -> CFDictionary { + [ + kSecReturnData: true, + kSecAttrAccount: key, + kSecAttrService: service, + kSecMatchLimit: kSecMatchLimitOne, + kSecClass: kSecClassGenericPassword + ] as CFDictionary + } + + private func deletionRequestForKey(_ key: String) -> CFDictionary { + [ + kSecAttrAccount: key, + kSecAttrService: service, + kSecClass: kSecClassGenericPassword + ] as CFDictionary + } + + private func attributes(for data: Data, key: String) -> CFDictionary? { + [ + kSecValueData: data, + kSecAttrAccount: key, + kSecAttrService: service, + kSecClass: kSecClassGenericPassword, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] as CFDictionary + } +} diff --git a/Sources/metamask-ios-sdk/Classes/Persistence/SessionConfig.swift b/Sources/metamask-ios-sdk/Classes/Persistence/SessionConfig.swift new file mode 100644 index 0000000..9e7ad16 --- /dev/null +++ b/Sources/metamask-ios-sdk/Classes/Persistence/SessionConfig.swift @@ -0,0 +1,20 @@ +// +// SessionConfig.swift +// metamask-ios-sdk +// + +import Foundation + +class SessionConfig: Codable { + let sessionId: String + let expiry: Date + + var isValid: Bool { + expiry > Date() + } + + init(sessionId: String, expiry: Date) { + self.sessionId = sessionId + self.expiry = expiry + } +} diff --git a/Sources/metamask-ios-sdk/Classes/Qrcode/QRCodeGenerator.swift b/Sources/metamask-ios-sdk/Classes/Qrcode/QRCodeGenerator.swift deleted file mode 100644 index 3490136..0000000 --- a/Sources/metamask-ios-sdk/Classes/Qrcode/QRCodeGenerator.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// QRCodeGenerator.swift -// metamask-ios-sdk -// - -import SwiftUI -import CoreImage.CIFilterBuiltins - -public func generateQRCode(from url: String) -> UIImage? { - let data = Data(url.utf8) - - let context = CIContext() - let filter = CIFilter.qrCodeGenerator() - filter.setValue(data, forKey: "inputMessage") - - guard - let qrCodeCIImage = filter.outputImage, - let qrCodeCGImage = context.createCGImage(qrCodeCIImage, from: qrCodeCIImage.extent) - else { return nil } - - return UIImage(cgImage: qrCodeCGImage) -} diff --git a/Sources/metamask-ios-sdk/Classes/Qrcode/QRCodeView.swift b/Sources/metamask-ios-sdk/Classes/Qrcode/QRCodeView.swift deleted file mode 100644 index 3a03465..0000000 --- a/Sources/metamask-ios-sdk/Classes/Qrcode/QRCodeView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// QRCodeView.swift -// metamask-ios-sdk -// - -import SwiftUI - -public struct QRCodeView: View { - let url: String - private let metamaskTint = Color(red: 241 / 255, green: 215 / 255, blue: 181 / 255) - - public init(url: String) { - self.url = url - } - - public var body: some View { - if let image = generateQRCode(from: url) { - Image(uiImage: image) - .interpolation(.none) - .resizable() - .frame( - width: 200, - height: 200, - alignment: .center - ) - .colorMultiply(metamaskTint) - } - } -} - -struct SwiftUIView_Previews: PreviewProvider { - static var previews: some View { - QRCodeView(url: "https://metamask.io/sdk/") - } -} diff --git a/Sources/metamask-ios-sdk/Classes/SDK/MetaMaskSDK.swift b/Sources/metamask-ios-sdk/Classes/SDK/MetaMaskSDK.swift index 11db4dd..afb864c 100644 --- a/Sources/metamask-ios-sdk/Classes/SDK/MetaMaskSDK.swift +++ b/Sources/metamask-ios-sdk/Classes/SDK/MetaMaskSDK.swift @@ -7,12 +7,13 @@ import Combine protocol SDKDelegate: AnyObject { var dapp: Dapp? { get set } + var deeplinkUrl: String { get } var enableDebug: Bool { get set } var networkUrl: String { get set } - var onClientsReady: (() -> Void)? { get set } - func connect() func disconnect() + func clearSession() + func addRequest(_ job: @escaping RequestJob) func sendMessage(_ message: T, encrypt: Bool) } @@ -35,6 +36,18 @@ public class MetaMaskSDK: ObservableObject, SDKDelegate { public var isConnected: Bool { client.isConnected } + + public var hasValidSession: Bool { + client.hasValidSession + } + + public var sessionDuration: TimeInterval { + get { + client.sessionDuration + } set { + client.sessionDuration = newValue + } + } var networkUrl: String { get { @@ -43,21 +56,27 @@ public class MetaMaskSDK: ObservableObject, SDKDelegate { client.serverUrl = newValue } } + + var deeplinkUrl: String { + client.deeplinkUrl + } var dapp: Dapp? { didSet { client.dapp = dapp } } - - var onClientsReady: (() -> Void)? { - didSet { - client.onClientsReady = onClientsReady - } + + func addRequest(_ job: @escaping RequestJob) { + client.addRequest(job) + } + + public convenience init(store: SecureStore = Keychain(service: SDKInfo.bundleIdentifier)) { + self.init(client: SocketClient(store: store, tracker: Analytics(debug: true))) } - private init(tracker: Tracking = Analytics(debug: true)) { - client = SocketClient(tracker: tracker) + init(client: CommunicationClient) { + self.client = client ethereum.delegate = self setupClientCommunication() @@ -70,6 +89,7 @@ private extension MetaMaskSDK { client.receiveEvent = ethereum.receiveEvent client.tearDownConnection = ethereum.disconnect client.receiveResponse = ethereum.receiveResponse + client.onClientsTerminated = ethereum.terminateConnection } func setupAppLifeCycleObservers() { @@ -107,6 +127,10 @@ extension MetaMaskSDK { func disconnect() { client.disconnect() } + + func clearSession() { + client.clearSession() + } func sendMessage(_ message: T, encrypt: Bool) { client.sendMessage(message, encrypt: encrypt) diff --git a/Sources/metamask-ios-sdk/Classes/SDK/SDKInfo.swift b/Sources/metamask-ios-sdk/Classes/SDK/SDKInfo.swift index ff3ef49..68de00d 100644 --- a/Sources/metamask-ios-sdk/Classes/SDK/SDKInfo.swift +++ b/Sources/metamask-ios-sdk/Classes/SDK/SDKInfo.swift @@ -5,19 +5,24 @@ import UIKit import Foundation -enum SDKInfo { +public enum SDKInfo { /// Bundle with SDK plist - static var sdkBundle: [String: Any] { + public static var sdkBundle: [String: Any] { Bundle(for: MetaMaskSDK.self).infoDictionary ?? [:] } /// The version number of the SDK e.g `1.0.0` - static var version: String { + public static var version: String { sdkBundle["CFBundleShortVersionString"] as? String ?? "" } + /// The bundle identifier of the dapp + public static var bundleIdentifier: String { + Bundle.main.bundleIdentifier ?? UUID().uuidString + } + /// The platform OS on which the SDK is run e.g `ios, ipados` - static var platform: String { + public static var platform: String { UIDevice.current.systemName.lowercased() } } diff --git a/metamask-ios-sdk.podspec b/metamask-ios-sdk.podspec index 88e4165..258632a 100644 --- a/metamask-ios-sdk.podspec +++ b/metamask-ios-sdk.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'metamask-ios-sdk' - s.version = '0.1.1' + s.version = '0.2.0' s.summary = 'Enable users to easily connect with their MetaMask Mobile wallet.' s.swift_version = '5.0'