diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 0e3715428..caba3649b 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -13,15 +13,14 @@ runs: using: "composite" steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/cache@v3 with: path: | **/SourcePackagesCache - DerivedDataCache key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - name: Build for testing shell: bash diff --git a/.github/actions/run_tests_without_building/action.yml b/.github/actions/run_tests_without_building/action.yml index 4a9b2a44c..f9567cf73 100644 --- a/.github/actions/run_tests_without_building/action.yml +++ b/.github/actions/run_tests_without_building/action.yml @@ -11,6 +11,9 @@ inputs: project-id: description: 'WalletConnect project id' required: true + slack-webhook-url: + description: 'Smoke tests slack webhoook url' + required: true runs: using: "composite" @@ -53,13 +56,13 @@ runs: run: make smoke_tests RELAY_HOST=${{ inputs.relay-endpoint }} PROJECT_ID=${{ inputs.project-id }} - name: Slack Notification for Failure - if: failure() && matrix.type == 'smoke-tests' + if: failure() && (inputs.type == 'smoke-tests' || inputs.type == 'relay-tests') uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} text: The smoke tests failed in the CI pipeline. Check the logs for more details. env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} - name: Publish Test Report uses: mikepenz/action-junit-report@v3 diff --git a/.github/workflows/build_artifacts.yml b/.github/workflows/build_artifacts.yml index b51a09e9f..cc8adb7a5 100644 --- a/.github/workflows/build_artifacts.yml +++ b/.github/workflows/build_artifacts.yml @@ -25,10 +25,7 @@ jobs: with: path: | **/SourcePackagesCache - DerivedDataCache key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - name: Build for testing on workflow_dispatch if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index bb5ce36ae..500646255 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,4 +28,4 @@ jobs: - name: Lint CocoaPods run: | - pod lib lint WalletConnectSwiftV2.podspec --verbose --allow-warnings \ No newline at end of file + pod lib lint --verbose --no-clean --quick --allow-warnings --platforms=ios WalletConnectSwiftV2.podspec \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6ced26fd..7acd8274b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: runs-on: macos-12 steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: ./.github/actions/build with: @@ -41,6 +43,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/cache/restore@v3 with: diff --git a/.gitignore b/.gitignore index 172334855..9ad6aa3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ xcuserdata/ # Swift Package Manager Packages/ Package.pins -Package.resolved /*.xcodeproj # Fastlane diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectHistory.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectHistory.xcscheme new file mode 100644 index 000000000..4311c49f7 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/WalletConnectHistory.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/DApp/SceneDelegate.swift b/Example/DApp/SceneDelegate.swift index f6b051abc..fc931226d 100644 --- a/Example/DApp/SceneDelegate.swift +++ b/Example/DApp/SceneDelegate.swift @@ -2,6 +2,7 @@ import UIKit import Auth import WalletConnectRelay import WalletConnectNetworking +import Web3Modal class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -13,6 +14,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { Networking.configure(projectId: InputConfig.projectId, socketFactory: DefaultSocketFactory()) Auth.configure(crypto: DefaultCryptoProvider()) + + let metadata = AppMetadata( + name: "Swift Dapp", + description: "WalletConnect DApp sample", + url: "wallet.connect", + icons: ["https://avatars.githubusercontent.com/u/37784886"] + ) + + Web3Modal.configure(projectId: InputConfig.projectId, metadata: metadata) setupWindow(scene: scene) } diff --git a/Example/DApp/Sign/Connect/ConnectViewController.swift b/Example/DApp/Sign/Connect/ConnectViewController.swift index ccb0d6fa2..094b82b6e 100644 --- a/Example/DApp/Sign/Connect/ConnectViewController.swift +++ b/Example/DApp/Sign/Connect/ConnectViewController.swift @@ -1,7 +1,6 @@ import Foundation import UIKit -import WalletConnectSign -import WalletConnectPairing +import Web3Modal class ConnectViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { let uri: WalletConnectURI diff --git a/Example/DApp/Sign/SelectChain/SelectChainViewController.swift b/Example/DApp/Sign/SelectChain/SelectChainViewController.swift index 5c456a453..4bbf7a67e 100644 --- a/Example/DApp/Sign/SelectChain/SelectChainViewController.swift +++ b/Example/DApp/Sign/SelectChain/SelectChainViewController.swift @@ -1,6 +1,5 @@ import Foundation -import WalletConnectSign -import WalletConnectPairing +import Web3Modal import UIKit import Combine @@ -70,16 +69,18 @@ class SelectChainViewController: UIViewController, UITableViewDataSource { let sessionProperties: [String: String] = [ "caip154-mandatory": "true" ] + Task { - let uri = try await Pair.instance.create() - try await Sign.instance.connect( + Web3Modal.set(sessionParams: .init( requiredNamespaces: namespaces, optionalNamespaces: optionalNamespaces, - sessionProperties: sessionProperties, - topic: uri.topic - ) - showConnectScreen(uri: uri) + sessionProperties: sessionProperties + )) + + let uri = try await Web3Modal.instance.connect(topic: nil) } + + Web3Modal.present(from: self) } @objc diff --git a/Example/ExampleApp.xcodeproj/IntegrationTests.xctestplan b/Example/ExampleApp.xcodeproj/IntegrationTests.xctestplan index 597f8cc1f..47c6a2314 100644 --- a/Example/ExampleApp.xcodeproj/IntegrationTests.xctestplan +++ b/Example/ExampleApp.xcodeproj/IntegrationTests.xctestplan @@ -30,7 +30,8 @@ { "skippedTests" : [ "AuthTests\/testEIP1271RespondSuccess()", - "ChatTests" + "ChatTests", + "ENSResolverTests" ], "target" : { "containerPath" : "container:ExampleApp.xcodeproj", diff --git a/Example/ExampleApp.xcodeproj/project.pbxproj b/Example/ExampleApp.xcodeproj/project.pbxproj index 35ab930db..7fcb4ca7e 100644 --- a/Example/ExampleApp.xcodeproj/project.pbxproj +++ b/Example/ExampleApp.xcodeproj/project.pbxproj @@ -75,19 +75,28 @@ 84FE684628ACDB4700C893FF /* RequestParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FE684528ACDB4700C893FF /* RequestParams.swift */; }; A507BE1A29E8032E0038EF70 /* EIP55Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A507BE1929E8032E0038EF70 /* EIP55Tests.swift */; }; A50C036528AAD32200FE72D3 /* ClientDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50C036428AAD32200FE72D3 /* ClientDelegate.swift */; }; + A50DF19D2A25084A0036EA6C /* WalletConnectHistory in Frameworks */ = {isa = PBXBuildFile; productRef = A50DF19C2A25084A0036EA6C /* WalletConnectHistory */; }; A50F3946288005B200064555 /* Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50F3945288005B200064555 /* Types.swift */; }; + A51606F82A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51606F72A2F47BD00CACB92 /* DefaultBIP44Provider.swift */; }; + A51606F92A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51606F72A2F47BD00CACB92 /* DefaultBIP44Provider.swift */; }; + A51606FA2A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51606F72A2F47BD00CACB92 /* DefaultBIP44Provider.swift */; }; + A51606FB2A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51606F72A2F47BD00CACB92 /* DefaultBIP44Provider.swift */; }; A518A98729683FB60035247E /* Web3InboxViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518A98429683FB60035247E /* Web3InboxViewController.swift */; }; A518A98829683FB60035247E /* Web3InboxModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518A98529683FB60035247E /* Web3InboxModule.swift */; }; A518A98929683FB60035247E /* Web3InboxRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518A98629683FB60035247E /* Web3InboxRouter.swift */; }; A518B31428E33A6500A2CE93 /* InputConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518B31328E33A6500A2CE93 /* InputConfig.swift */; }; A51AC0D928E436A3001BACF9 /* InputConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51AC0D828E436A3001BACF9 /* InputConfig.swift */; }; A51AC0DF28E4379F001BACF9 /* InputConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51AC0DE28E4379F001BACF9 /* InputConfig.swift */; }; + A5321C2B2A250367006CADC3 /* HistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5321C2A2A250367006CADC3 /* HistoryTests.swift */; }; A5417BBE299BFC3E00B469F3 /* ImportAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5417BBD299BFC3E00B469F3 /* ImportAccount.swift */; }; A541959E2934BFEF0035AD19 /* CacaoSignerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A541959A2934BFEF0035AD19 /* CacaoSignerTests.swift */; }; A541959F2934BFEF0035AD19 /* SignerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A541959B2934BFEF0035AD19 /* SignerTests.swift */; }; A54195A02934BFEF0035AD19 /* EIP1271VerifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A541959C2934BFEF0035AD19 /* EIP1271VerifierTests.swift */; }; A54195A12934BFEF0035AD19 /* EIP191VerifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A541959D2934BFEF0035AD19 /* EIP191VerifierTests.swift */; }; A54195A52934E83F0035AD19 /* Web3 in Frameworks */ = {isa = PBXBuildFile; productRef = A54195A42934E83F0035AD19 /* Web3 */; }; + A561C80029DF32CE00DF540D /* HDWalletKit in Frameworks */ = {isa = PBXBuildFile; productRef = A561C7FF29DF32CE00DF540D /* HDWalletKit */; }; + A561C80329DFCCDC00DF540D /* SyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A561C80229DFCCDC00DF540D /* SyncTests.swift */; }; + A561C80529DFCD4500DF540D /* WalletConnectSync in Frameworks */ = {isa = PBXBuildFile; productRef = A561C80429DFCD4500DF540D /* WalletConnectSync */; }; A5629AA92876A23100094373 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5629AA82876A23100094373 /* ChatService.swift */; }; A5629ABD2876CBC000094373 /* ChatListModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5629AB82876CBC000094373 /* ChatListModule.swift */; }; A5629ABE2876CBC000094373 /* ChatListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5629AB92876CBC000094373 /* ChatListPresenter.swift */; }; @@ -108,6 +117,10 @@ A5629AE828772A0100094373 /* InviteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5629AE728772A0100094373 /* InviteViewModel.swift */; }; A5629AEA2877F2D600094373 /* WalletConnectChat in Frameworks */ = {isa = PBXBuildFile; productRef = A5629AE92877F2D600094373 /* WalletConnectChat */; }; A5629AF22877F75100094373 /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = A5629AF12877F75100094373 /* Starscream */; }; + A573C53729EC34A600E3CBFD /* SyncDerivationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A573C53629EC34A600E3CBFD /* SyncDerivationServiceTests.swift */; }; + A573C53929EC365000E3CBFD /* HDWalletKit in Frameworks */ = {isa = PBXBuildFile; productRef = A573C53829EC365000E3CBFD /* HDWalletKit */; }; + A573C53B29EC365800E3CBFD /* HDWalletKit in Frameworks */ = {isa = PBXBuildFile; productRef = A573C53A29EC365800E3CBFD /* HDWalletKit */; }; + A573C53D29EC366500E3CBFD /* HDWalletKit in Frameworks */ = {isa = PBXBuildFile; productRef = A573C53C29EC366500E3CBFD /* HDWalletKit */; }; A578FA322873036400AA7720 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A578FA312873036400AA7720 /* InputView.swift */; }; A578FA35287304A300AA7720 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = A578FA34287304A300AA7720 /* Color.swift */; }; A578FA372873D8EE00AA7720 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A578FA362873D8EE00AA7720 /* UIColor.swift */; }; @@ -192,6 +205,7 @@ A5E22D222840C8D300E36487 /* WalletEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E22D212840C8D300E36487 /* WalletEngine.swift */; }; A5E22D242840C8DB00E36487 /* SafariEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E22D232840C8DB00E36487 /* SafariEngine.swift */; }; A5E22D2C2840EAC300E36487 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E22D2B2840EAC300E36487 /* XCUIElement.swift */; }; + A5E776BA29F4362D00172091 /* AlertError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E776B929F4362D00172091 /* AlertError.swift */; }; A74D32BA2A1E25AD00CB8536 /* QueryParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D32B92A1E25AD00CB8536 /* QueryParameters.swift */; }; C5133A78294125CC00A8314C /* Web3 in Frameworks */ = {isa = PBXBuildFile; productRef = C5133A77294125CC00A8314C /* Web3 */; }; C53AA4362941251C008EA57C /* DefaultSignerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59CF4F5292F83D50031A42F /* DefaultSignerFactory.swift */; }; @@ -268,6 +282,7 @@ C5F32A322954816C00A6476E /* ConnectionDetailsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F32A312954816C00A6476E /* ConnectionDetailsPresenter.swift */; }; C5F32A342954817600A6476E /* ConnectionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F32A332954817600A6476E /* ConnectionDetailsView.swift */; }; C5F32A362954FE3C00A6476E /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C5F32A352954FE3C00A6476E /* Colors.xcassets */; }; + CF140F2D2A2A288D00BEB791 /* Web3Modal in Frameworks */ = {isa = PBXBuildFile; productRef = CF140F2C2A2A288D00BEB791 /* Web3Modal */; }; CF1A594529E5876600AAC16B /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF1A593A29E5876600AAC16B /* XCUIElement.swift */; }; CF1A594629E5876600AAC16B /* PushNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF1A593C29E5876600AAC16B /* PushNotificationTests.swift */; }; CF1A594829E5876600AAC16B /* Engine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF1A593F29E5876600AAC16B /* Engine.swift */; }; @@ -406,17 +421,20 @@ A507BE1929E8032E0038EF70 /* EIP55Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EIP55Tests.swift; sourceTree = ""; }; A50C036428AAD32200FE72D3 /* ClientDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClientDelegate.swift; sourceTree = ""; }; A50F3945288005B200064555 /* Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Types.swift; sourceTree = ""; }; + A51606F72A2F47BD00CACB92 /* DefaultBIP44Provider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultBIP44Provider.swift; sourceTree = ""; }; A518A98429683FB60035247E /* Web3InboxViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Web3InboxViewController.swift; sourceTree = ""; }; A518A98529683FB60035247E /* Web3InboxModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Web3InboxModule.swift; sourceTree = ""; }; A518A98629683FB60035247E /* Web3InboxRouter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Web3InboxRouter.swift; sourceTree = ""; }; A518B31328E33A6500A2CE93 /* InputConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputConfig.swift; sourceTree = ""; }; A51AC0D828E436A3001BACF9 /* InputConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputConfig.swift; sourceTree = ""; }; A51AC0DE28E4379F001BACF9 /* InputConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputConfig.swift; sourceTree = ""; }; + A5321C2A2A250367006CADC3 /* HistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTests.swift; sourceTree = ""; }; A5417BBD299BFC3E00B469F3 /* ImportAccount.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImportAccount.swift; sourceTree = ""; }; A541959A2934BFEF0035AD19 /* CacaoSignerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CacaoSignerTests.swift; sourceTree = ""; }; A541959B2934BFEF0035AD19 /* SignerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignerTests.swift; sourceTree = ""; }; A541959C2934BFEF0035AD19 /* EIP1271VerifierTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EIP1271VerifierTests.swift; sourceTree = ""; }; A541959D2934BFEF0035AD19 /* EIP191VerifierTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EIP191VerifierTests.swift; sourceTree = ""; }; + A561C80229DFCCDC00DF540D /* SyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTests.swift; sourceTree = ""; }; A5629AA82876A23100094373 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = ""; }; A5629AB82876CBC000094373 /* ChatListModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListModule.swift; sourceTree = ""; }; A5629AB92876CBC000094373 /* ChatListPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListPresenter.swift; sourceTree = ""; }; @@ -436,6 +454,7 @@ A5629AE32876E6D200094373 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; A5629AE728772A0100094373 /* InviteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteViewModel.swift; sourceTree = ""; }; A5629AEF2877F73000094373 /* DefaultSocketFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultSocketFactory.swift; sourceTree = ""; }; + A573C53629EC34A600E3CBFD /* SyncDerivationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDerivationServiceTests.swift; sourceTree = ""; }; A578FA312873036400AA7720 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = ""; }; A578FA34287304A300AA7720 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; A578FA362873D8EE00AA7720 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; @@ -508,6 +527,7 @@ A5E22D212840C8D300E36487 /* WalletEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletEngine.swift; sourceTree = ""; }; A5E22D232840C8DB00E36487 /* SafariEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariEngine.swift; sourceTree = ""; }; A5E22D2B2840EAC300E36487 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; + A5E776B929F4362D00172091 /* AlertError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertError.swift; sourceTree = ""; }; A5F48A0528E43D3F0034CBFB /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Configuration.xcconfig; path = ../Configuration.xcconfig; sourceTree = ""; }; A74D32B92A1E25AD00CB8536 /* QueryParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParameters.swift; sourceTree = ""; }; C55D347A295DD7140004314A /* AuthRequestModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRequestModule.swift; sourceTree = ""; }; @@ -604,7 +624,9 @@ 8448F1D427E4726F0000B866 /* WalletConnect in Frameworks */, A54195A52934E83F0035AD19 /* Web3 in Frameworks */, 84E6B8652981720400428BAF /* WalletConnectPush in Frameworks */, + CF140F2D2A2A288D00BEB791 /* Web3Modal in Frameworks */, A5D85228286333E300DAF5C3 /* Starscream in Frameworks */, + A573C53929EC365000E3CBFD /* HDWalletKit in Frameworks */, A5BB7FA328B6A50400707FC6 /* WalletConnectAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -624,6 +646,7 @@ CF9C7E4A2A01802F0037C006 /* Web3Modal in Frameworks */, A58EC618299D665A00F3452A /* Web3Inbox in Frameworks */, A5629AEA2877F2D600094373 /* WalletConnectChat in Frameworks */, + A561C80029DF32CE00DF540D /* HDWalletKit in Frameworks */, A59FAEC928B7B93A002BB66F /* Web3 in Frameworks */, A5629AF22877F75100094373 /* Starscream in Frameworks */, A59F877628B5462900A9CD80 /* WalletConnectAuth in Frameworks */, @@ -643,11 +666,14 @@ buildActionMask = 2147483647; files = ( A5E03DFF2864662500888481 /* WalletConnect in Frameworks */, + A561C80529DFCD4500DF540D /* WalletConnectSync in Frameworks */, A5E03DF52864651200888481 /* Starscream in Frameworks */, + A50DF19D2A25084A0036EA6C /* WalletConnectHistory in Frameworks */, 847CF3AF28E3141700F1D760 /* WalletConnectPush in Frameworks */, A5C8BE85292FE20B006CC85C /* Web3 in Frameworks */, 84DDB4ED28ABB663003D66ED /* WalletConnectAuth in Frameworks */, C5DD5BE1294E09E3008FD3A4 /* Web3Wallet in Frameworks */, + A573C53B29EC365800E3CBFD /* HDWalletKit in Frameworks */, A5E03E01286466EA00888481 /* WalletConnectChat in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -656,6 +682,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A573C53D29EC366500E3CBFD /* HDWalletKit in Frameworks */, C56EE27D293F56F8004840D1 /* WalletConnectChat in Frameworks */, C5133A78294125CC00A8314C /* Web3 in Frameworks */, 84536D7429EEBCF0008EA8DB /* Web3Inbox in Frameworks */, @@ -932,6 +959,14 @@ path = Types; sourceTree = ""; }; + A5321C292A25035A006CADC3 /* History */ = { + isa = PBXGroup; + children = ( + A5321C2A2A250367006CADC3 /* HistoryTests.swift */, + ); + path = History; + sourceTree = ""; + }; A54195992934BFDD0035AD19 /* Signer */ = { isa = PBXGroup; children = ( @@ -944,6 +979,15 @@ path = Signer; sourceTree = ""; }; + A561C80129DFCCD300DF540D /* Sync */ = { + isa = PBXGroup; + children = ( + A561C80229DFCCDC00DF540D /* SyncTests.swift */, + A573C53629EC34A600E3CBFD /* SyncDerivationServiceTests.swift */, + ); + path = Sync; + sourceTree = ""; + }; A5629AA42876A19D00094373 /* DomainLayer */ = { isa = PBXGroup; children = ( @@ -995,6 +1039,7 @@ isa = PBXGroup; children = ( A5629AE32876E6D200094373 /* ThreadViewModel.swift */, + A5E776B929F4362D00172091 /* AlertError.swift */, ); path = Models; sourceTree = ""; @@ -1260,6 +1305,7 @@ A5629AEF2877F73000094373 /* DefaultSocketFactory.swift */, A59CF4F5292F83D50031A42F /* DefaultSignerFactory.swift */, A5A0843B29D2F60A000B9B17 /* DefaultCryptoProvider.swift */, + A51606F72A2F47BD00CACB92 /* DefaultBIP44Provider.swift */, ); path = Shared; sourceTree = ""; @@ -1342,6 +1388,8 @@ A5E03DEE286464DB00888481 /* IntegrationTests */ = { isa = PBXGroup; children = ( + A5321C292A25035A006CADC3 /* History */, + A561C80129DFCCD300DF540D /* Sync */, 849D7A91292E2115006A2BD4 /* Push */, 84CEC64728D8A98900D081A8 /* Pairing */, A5E03E0A28646A8A00888481 /* Stubs */, @@ -1730,6 +1778,8 @@ A5BB7FA228B6A50400707FC6 /* WalletConnectAuth */, A54195A42934E83F0035AD19 /* Web3 */, 84E6B8642981720400428BAF /* WalletConnectPush */, + CF140F2C2A2A288D00BEB791 /* Web3Modal */, + A573C53829EC365000E3CBFD /* HDWalletKit */, ); productName = DApp; productReference = 84CE641C27981DED00142511 /* DApp.app */; @@ -1776,6 +1826,7 @@ A58EC610299D57B800F3452A /* AsyncButton */, A58EC617299D665A00F3452A /* Web3Inbox */, CF9C7E492A01802F0037C006 /* Web3Modal */, + A561C7FF29DF32CE00DF540D /* HDWalletKit */, ); productName = Showcase; productReference = A58E7CE828729F550082D443 /* Showcase.app */; @@ -1820,6 +1871,9 @@ 847CF3AE28E3141700F1D760 /* WalletConnectPush */, A5C8BE84292FE20B006CC85C /* Web3 */, C5DD5BE0294E09E3008FD3A4 /* Web3Wallet */, + A561C80429DFCD4500DF540D /* WalletConnectSync */, + A573C53A29EC365800E3CBFD /* HDWalletKit */, + A50DF19C2A25084A0036EA6C /* WalletConnectHistory */, ); productName = IntegrationTests; productReference = A5E03DED286464DB00888481 /* IntegrationTests.xctest */; @@ -1849,6 +1903,7 @@ C5B2F7042970573D000DBA0E /* SolanaSwift */, 84E6B85329787AAE00428BAF /* WalletConnectPush */, 84536D7329EEBCF0008EA8DB /* Web3Inbox */, + A573C53C29EC366500E3CBFD /* HDWalletKit */, ); productName = ChatWallet; productReference = C56EE21B293F55ED004840D1 /* WalletApp.app */; @@ -1923,6 +1978,7 @@ A5AE354528A1A2AC0059AE8A /* XCRemoteSwiftPackageReference "Web3" */, A5434021291E6A270068F706 /* XCRemoteSwiftPackageReference "solana-swift" */, A58EC60F299D57B800F3452A /* XCRemoteSwiftPackageReference "swiftui-async-button" */, + A561C7FE29DF32CE00DF540D /* XCRemoteSwiftPackageReference "HDWallet" */, ); productRefGroup = 764E1D3D26F8D3FC00A1FB15 /* Products */; projectDirPath = ""; @@ -2039,6 +2095,7 @@ 84CE644E279ED2FF00142511 /* SelectChainView.swift in Sources */, 84CE644B279EA1FA00142511 /* AccountRequestView.swift in Sources */, 84CE6431279820F600142511 /* AccountsView.swift in Sources */, + A51606F82A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */, 84CE643D2798322600142511 /* ConnectViewController.swift in Sources */, 84CE6444279AB5AD00142511 /* SelectChainViewController.swift in Sources */, ); @@ -2083,8 +2140,10 @@ A58E7D3D2872D55F0082D443 /* ChatView.swift in Sources */, A5629ABD2876CBC000094373 /* ChatListModule.swift in Sources */, A58E7CEB28729F550082D443 /* AppDelegate.swift in Sources */, + A51606FA2A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */, A578FA35287304A300AA7720 /* Color.swift in Sources */, A5629ADE2876CC6E00094373 /* InviteListModule.swift in Sources */, + A5E776BA29F4362D00172091 /* AlertError.swift in Sources */, A578FA322873036400AA7720 /* InputView.swift in Sources */, A5A0843F29D2F625000B9B17 /* DefaultCryptoProvider.swift in Sources */, A5C2021B287E1FD8007E3188 /* ImportRouter.swift in Sources */, @@ -2149,8 +2208,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A51606F92A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */, + A573C53729EC34A600E3CBFD /* SyncDerivationServiceTests.swift in Sources */, A5A0843E29D2F624000B9B17 /* DefaultCryptoProvider.swift in Sources */, 84CEC64628D89D6B00D081A8 /* PairingTests.swift in Sources */, + A561C80329DFCCDC00DF540D /* SyncTests.swift in Sources */, 767DC83528997F8E00080FA9 /* EthSendTransaction.swift in Sources */, 8439CB89293F658E00F2F2E2 /* PushMessage.swift in Sources */, A518B31428E33A6500A2CE93 /* InputConfig.swift in Sources */, @@ -2164,6 +2226,7 @@ 7694A5262874296A0001257E /* RegistryTests.swift in Sources */, A541959F2934BFEF0035AD19 /* SignerTests.swift in Sources */, A50C036528AAD32200FE72D3 /* ClientDelegate.swift in Sources */, + A5321C2B2A250367006CADC3 /* HistoryTests.swift in Sources */, A58A1ECC29BF458600A82A20 /* ENSResolverTests.swift in Sources */, A5E03DFA286465C700888481 /* SignClientTests.swift in Sources */, A54195A02934BFEF0035AD19 /* EIP1271VerifierTests.swift in Sources */, @@ -2198,6 +2261,7 @@ C56EE241293F566D004840D1 /* WalletModule.swift in Sources */, C56EE245293F566D004840D1 /* WalletPresenter.swift in Sources */, C56EE240293F566D004840D1 /* ScanQRView.swift in Sources */, + A51606FB2A2F47BD00CACB92 /* DefaultBIP44Provider.swift in Sources */, C56EE250293F566D004840D1 /* ScanTargetView.swift in Sources */, C56EE28F293F5757004840D1 /* MigrationConfigurator.swift in Sources */, 84B815552991217900FAD54E /* PushMessagesPresenter.swift in Sources */, @@ -2989,6 +3053,14 @@ kind = branch; }; }; + A561C7FE29DF32CE00DF540D /* XCRemoteSwiftPackageReference "HDWallet" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/WalletConnect/HDWallet"; + requirement = { + branch = develop; + kind = branch; + }; + }; A58EC60F299D57B800F3452A /* XCRemoteSwiftPackageReference "swiftui-async-button" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/lorenzofiamingo/swiftui-async-button"; @@ -3053,11 +3125,24 @@ isa = XCSwiftPackageProductDependency; productName = WalletConnectPush; }; + A50DF19C2A25084A0036EA6C /* WalletConnectHistory */ = { + isa = XCSwiftPackageProductDependency; + productName = WalletConnectHistory; + }; A54195A42934E83F0035AD19 /* Web3 */ = { isa = XCSwiftPackageProductDependency; package = A5AE354528A1A2AC0059AE8A /* XCRemoteSwiftPackageReference "Web3" */; productName = Web3; }; + A561C7FF29DF32CE00DF540D /* HDWalletKit */ = { + isa = XCSwiftPackageProductDependency; + package = A561C7FE29DF32CE00DF540D /* XCRemoteSwiftPackageReference "HDWallet" */; + productName = HDWalletKit; + }; + A561C80429DFCD4500DF540D /* WalletConnectSync */ = { + isa = XCSwiftPackageProductDependency; + productName = WalletConnectSync; + }; A5629AE92877F2D600094373 /* WalletConnectChat */ = { isa = XCSwiftPackageProductDependency; productName = WalletConnectChat; @@ -3067,6 +3152,21 @@ package = A5D85224286333D500DAF5C3 /* XCRemoteSwiftPackageReference "Starscream" */; productName = Starscream; }; + A573C53829EC365000E3CBFD /* HDWalletKit */ = { + isa = XCSwiftPackageProductDependency; + package = A561C7FE29DF32CE00DF540D /* XCRemoteSwiftPackageReference "HDWallet" */; + productName = HDWalletKit; + }; + A573C53A29EC365800E3CBFD /* HDWalletKit */ = { + isa = XCSwiftPackageProductDependency; + package = A561C7FE29DF32CE00DF540D /* XCRemoteSwiftPackageReference "HDWallet" */; + productName = HDWalletKit; + }; + A573C53C29EC366500E3CBFD /* HDWalletKit */ = { + isa = XCSwiftPackageProductDependency; + package = A561C7FE29DF32CE00DF540D /* XCRemoteSwiftPackageReference "HDWallet" */; + productName = HDWalletKit; + }; A58EC610299D57B800F3452A /* AsyncButton */ = { isa = XCSwiftPackageProductDependency; package = A58EC60F299D57B800F3452A /* XCRemoteSwiftPackageReference "swiftui-async-button" */; @@ -3143,6 +3243,10 @@ isa = XCSwiftPackageProductDependency; productName = Web3Wallet; }; + CF140F2C2A2A288D00BEB791 /* Web3Modal */ = { + isa = XCSwiftPackageProductDependency; + productName = Web3Modal; + }; CF9C7E492A01802F0037C006 /* Web3Modal */ = { isa = XCSwiftPackageProductDependency; productName = Web3Modal; diff --git a/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..b9a5ff3f7 --- /dev/null +++ b/Example/ExampleApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,133 @@ +{ + "object": { + "pins": [ + { + "package": "BigInt", + "repositoryURL": "https://github.com/attaswift/BigInt.git", + "state": { + "branch": null, + "revision": "0ed110f7555c34ff468e72e1686e59721f2b0da6", + "version": "5.3.0" + } + }, + { + "package": "CryptoSwift", + "repositoryURL": "https://github.com/krzyzanowskim/CryptoSwift.git", + "state": { + "branch": null, + "revision": "32f641cf24fc7abc1c591a2025e9f2f572648b0f", + "version": "1.7.2" + } + }, + { + "package": "HDWalletKit", + "repositoryURL": "https://github.com/WalletConnect/HDWallet", + "state": { + "branch": "develop", + "revision": "748a85b1dfe9a2fa592bd9266c5a926e4e1d3f44", + "version": null + } + }, + { + "package": "PromiseKit", + "repositoryURL": "https://github.com/mxcl/PromiseKit.git", + "state": { + "branch": null, + "revision": "8a98e31a47854d3180882c8068cc4d9381bf382d", + "version": "6.22.1" + } + }, + { + "package": "QRCode", + "repositoryURL": "https://github.com/WalletConnect/QRCode", + "state": { + "branch": null, + "revision": "263f280d2c8144adfb0b6676109846cfc8dd552b", + "version": "14.3.1" + } + }, + { + "package": "secp256k1", + "repositoryURL": "https://github.com/Boilertalk/secp256k1.swift.git", + "state": { + "branch": null, + "revision": "cd187c632fb812fd93711a9f7e644adb7e5f97f0", + "version": "0.1.7" + } + }, + { + "package": "SolanaSwift", + "repositoryURL": "https://github.com/flypaper0/solana-swift", + "state": { + "branch": "feature/available-13", + "revision": "a98811518e0a90c2dfc60c30cfd3ec85c33b6790", + "version": null + } + }, + { + "package": "Starscream", + "repositoryURL": "https://github.com/daltoniam/Starscream", + "state": { + "branch": null, + "revision": "a063fda2b8145a231953c20e7a646be254365396", + "version": "3.1.2" + } + }, + { + "package": "swift-qrcode-generator", + "repositoryURL": "https://github.com/dagronf/swift-qrcode-generator", + "state": { + "branch": null, + "revision": "5ca09b6a2ad190f94aa3d6ddef45b187f8c0343b", + "version": "1.0.3" + } + }, + { + "package": "SwiftImageReadWrite", + "repositoryURL": "https://github.com/dagronf/SwiftImageReadWrite", + "state": { + "branch": null, + "revision": "5596407d1cf61b953b8e658fa8636a471df3c509", + "version": "1.1.6" + } + }, + { + "package": "swiftui-async-button", + "repositoryURL": "https://github.com/lorenzofiamingo/swiftui-async-button", + "state": { + "branch": null, + "revision": "9fe9ccddf59c7e4185aa978547fbb9d95236455e", + "version": "1.1.0" + } + }, + { + "package": "Task_retrying", + "repositoryURL": "https://github.com/bigearsenal/task-retrying-swift.git", + "state": { + "branch": null, + "revision": "1249b3524378423c848cef39fb220041e00a08ec", + "version": "1.0.4" + } + }, + { + "package": "TweetNacl", + "repositoryURL": "https://github.com/bitmark-inc/tweetnacl-swiftwrap.git", + "state": { + "branch": null, + "revision": "f8fd111642bf2336b11ef9ea828510693106e954", + "version": "1.1.0" + } + }, + { + "package": "Web3", + "repositoryURL": "https://github.com/WalletConnect/Web3.swift", + "state": { + "branch": null, + "revision": "569255adcfff0b37e4cb8004aea29d0e2d6266df", + "version": "1.0.2" + } + } + ] + }, + "version": 1 +} diff --git a/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectSync.xcscheme b/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectSync.xcscheme new file mode 100644 index 000000000..4b58c89d1 --- /dev/null +++ b/Example/ExampleApp.xcodeproj/xcshareddata/xcschemes/WalletConnectSync.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/IntegrationTests/Auth/ENS/ENSResolverTests.swift b/Example/IntegrationTests/Auth/ENS/ENSResolverTests.swift index df4a26f3e..8ea3661e8 100644 --- a/Example/IntegrationTests/Auth/ENS/ENSResolverTests.swift +++ b/Example/IntegrationTests/Auth/ENS/ENSResolverTests.swift @@ -7,15 +7,16 @@ class ENSResolverTests: XCTestCase { private let account = Account("eip155:1:0xD02D090F8f99B61D65d8e8876Ea86c2720aB27BC")! private let ens = "web3.eth" - func testResolveEns() async throws { - let resolver = ENSResolverFactory(crypto: DefaultCryptoProvider()).create(projectId: InputConfig.projectId) - let resolved = try await resolver.resolveEns(account: account) - XCTAssertEqual(resolved, ens) - } - - func testResolveAddress() async throws { - let resolver = ENSResolverFactory(crypto: DefaultCryptoProvider()).create(projectId: InputConfig.projectId) - let resolved = try await resolver.resolveAddress(ens: ens, blockchain: account.blockchain) - XCTAssertEqual(resolved, account) - } +// Note: - removed until RPC server fix +// func testResolveEns() async throws { +// let resolver = ENSResolverFactory(crypto: DefaultCryptoProvider()).create(projectId: InputConfig.projectId) +// let resolved = try await resolver.resolveEns(account: account) +// XCTAssertEqual(resolved, ens) +// } +// +// func testResolveAddress() async throws { +// let resolver = ENSResolverFactory(crypto: DefaultCryptoProvider()).create(projectId: InputConfig.projectId) +// let resolved = try await resolver.resolveAddress(ens: ens, blockchain: account.blockchain) +// XCTAssertEqual(resolved, account) +// } } diff --git a/Example/IntegrationTests/Chat/ChatTests.swift b/Example/IntegrationTests/Chat/ChatTests.swift index 2c917aa35..c2aaab6c5 100644 --- a/Example/IntegrationTests/Chat/ChatTests.swift +++ b/Example/IntegrationTests/Chat/ChatTests.swift @@ -3,25 +3,46 @@ import XCTest @testable import WalletConnectChat import WalletConnectUtils @testable import WalletConnectKMS +@testable import WalletConnectSync import WalletConnectRelay import Combine +import Web3 final class ChatTests: XCTestCase { - var invitee: ChatClient! - var inviter: ChatClient! + var invitee1: ChatClient! + var inviter1: ChatClient! + var invitee2: ChatClient! + var inviter2: ChatClient! private var publishers = [AnyCancellable]() - let inviteeAccount = Account("eip155:1:0x15bca56b6e2728aec2532df9d436bd1600e86688")! - let inviterAccount = Account("eip155:2:0x15bca56b6e2728aec2532df9d436bd1600e86688")! + var inviteeAccount: Account { + return Account("eip155:1:" + pk1.address.hex(eip55: true))! + } + + var inviterAccount: Account { + return Account("eip155:1:" + pk2.address.hex(eip55: true))! + } - let privateKey = Data(hex: "305c6cde3846927892cd32762f6120539f3ec74c9e3a16b9b798b1e85351ae2a") + let pk1 = try! EthereumPrivateKey() + let pk2 = try! EthereumPrivateKey() - override func setUp() async throws { - invitee = makeClient(prefix: "🦖 Invitee", account: inviteeAccount) - inviter = makeClient(prefix: "🍄 Inviter", account: inviterAccount) + var privateKey1: Data { + return Data(pk1.rawPrivateKey) + } + var privateKey2: Data { + return Data(pk2.rawPrivateKey) + } - try await invitee.register(account: inviteeAccount, onSign: sign) - try await inviter.register(account: inviterAccount, onSign: sign) + override func setUp() async throws { + invitee1 = makeClient(prefix: "🦖 Invitee", account: inviteeAccount) + inviter1 = makeClient(prefix: "🍄 Inviter", account: inviterAccount) + + try await invitee1.register(account: inviteeAccount) { message in + return self.sign(message, privateKey: self.privateKey1) + } + try await inviter1.register(account: inviterAccount) { message in + return self.sign(message, privateKey: self.privateKey2) + } } func makeClient(prefix: String, account: Account) -> ChatClient { @@ -36,27 +57,33 @@ final class ChatTests: XCTestCase { keychainStorage: keychain, keyValueStorage: keyValueStorage) + let syncClient = SyncClientFactory.create( + networkInteractor: networkingInteractor, + bip44: DefaultBIP44Provider(), + keychain: keychain + ) + let clientId = try! networkingInteractor.getClientId() logger.debug("My client id is: \(clientId)") - return ChatClientFactory.create(keyserverURL: keyserverURL, relayClient: relayClient, networkingInteractor: networkingInteractor, keychain: keychain, logger: logger, keyValueStorage: keyValueStorage) + return ChatClientFactory.create(keyserverURL: keyserverURL, relayClient: relayClient, networkingInteractor: networkingInteractor, keychain: keychain, logger: logger, storage: keyValueStorage, syncClient: syncClient) } func testInvite() async throws { let inviteExpectation = expectation(description: "invitation expectation") inviteExpectation.expectedFulfillmentCount = 2 - invitee.newReceivedInvitePublisher.sink { _ in + invitee1.newReceivedInvitePublisher.sink { _ in inviteExpectation.fulfill() }.store(in: &publishers) - inviter.newSentInvitePublisher.sink { _ in + inviter1.newSentInvitePublisher.sink { _ in inviteExpectation.fulfill() }.store(in: &publishers) - let inviteePublicKey = try await inviter.resolve(account: inviteeAccount) + let inviteePublicKey = try await inviter1.resolve(account: inviteeAccount) let invite = Invite(message: "", inviterAccount: inviterAccount, inviteeAccount: inviteeAccount, inviteePublicKey: inviteePublicKey) - _ = try await inviter.invite(invite: invite) + _ = try await inviter1.invite(invite: invite) wait(for: [inviteExpectation], timeout: InputConfig.defaultTimeout) } @@ -65,21 +92,21 @@ final class ChatTests: XCTestCase { let newThreadInviterExpectation = expectation(description: "new thread on inviting client expectation") let newThreadinviteeExpectation = expectation(description: "new thread on invitee client expectation") - invitee.newReceivedInvitePublisher.sink { [unowned self] invite in - Task { try! await invitee.accept(inviteId: invite.id) } + invitee1.newReceivedInvitePublisher.sink { [unowned self] invite in + Task { try! await invitee1.accept(inviteId: invite.id) } }.store(in: &publishers) - invitee.newThreadPublisher.sink { _ in + invitee1.newThreadPublisher.sink { _ in newThreadinviteeExpectation.fulfill() }.store(in: &publishers) - inviter.newThreadPublisher.sink { _ in + inviter1.newThreadPublisher.sink { _ in newThreadInviterExpectation.fulfill() }.store(in: &publishers) - let inviteePublicKey = try await inviter.resolve(account: inviteeAccount) + let inviteePublicKey = try await inviter1.resolve(account: inviteeAccount) let invite = Invite(message: "", inviterAccount: inviterAccount, inviteeAccount: inviteeAccount, inviteePublicKey: inviteePublicKey) - try await inviter.invite(invite: invite) + try await inviter1.invite(invite: invite) wait(for: [newThreadinviteeExpectation, newThreadInviterExpectation], timeout: InputConfig.defaultTimeout) } @@ -88,19 +115,19 @@ final class ChatTests: XCTestCase { let messageExpectation = expectation(description: "message received") messageExpectation.expectedFulfillmentCount = 4 - invitee.newReceivedInvitePublisher.sink { [unowned self] invite in - Task { try! await invitee.accept(inviteId: invite.id) } + invitee1.newReceivedInvitePublisher.sink { [unowned self] invite in + Task { try! await invitee1.accept(inviteId: invite.id) } }.store(in: &publishers) - invitee.newThreadPublisher.sink { [unowned self] thread in - Task { try! await invitee.message(topic: thread.topic, message: "message1") } + invitee1.newThreadPublisher.sink { [unowned self] thread in + Task { try! await invitee1.message(topic: thread.topic, message: "message1") } }.store(in: &publishers) - inviter.newThreadPublisher.sink { [unowned self] thread in - Task { try! await inviter.message(topic: thread.topic, message: "message2") } + inviter1.newThreadPublisher.sink { [unowned self] thread in + Task { try! await inviter1.message(topic: thread.topic, message: "message2") } }.store(in: &publishers) - inviter.newMessagePublisher.sink { message in + inviter1.newMessagePublisher.sink { message in if message.authorAccount == self.inviterAccount { XCTAssertEqual(message.message, "message2") } else { @@ -109,7 +136,7 @@ final class ChatTests: XCTestCase { messageExpectation.fulfill() }.store(in: &publishers) - invitee.newMessagePublisher.sink { message in + invitee1.newMessagePublisher.sink { message in if message.authorAccount == self.inviteeAccount { XCTAssertEqual(message.message, "message1") } else { @@ -118,15 +145,14 @@ final class ChatTests: XCTestCase { messageExpectation.fulfill() }.store(in: &publishers) - let inviteePublicKey = try await inviter.resolve(account: inviteeAccount) + let inviteePublicKey = try await inviter1.resolve(account: inviteeAccount) let invite = Invite(message: "", inviterAccount: inviterAccount, inviteeAccount: inviteeAccount, inviteePublicKey: inviteePublicKey) - try await inviter.invite(invite: invite) + try await inviter1.invite(invite: invite) wait(for: [messageExpectation], timeout: InputConfig.defaultTimeout) } - private func sign(_ message: String) -> SigningResult { - let privateKey = Data(hex: "305c6cde3846927892cd32762f6120539f3ec74c9e3a16b9b798b1e85351ae2a") + private func sign(_ message: String, privateKey: Data) -> SigningResult { let signer = MessageSignerFactory(signerFactory: DefaultSignerFactory()).create(projectId: InputConfig.projectId) return .signed(try! signer.sign(message: message, privateKey: privateKey, type: .eip191)) } diff --git a/Example/IntegrationTests/History/HistoryTests.swift b/Example/IntegrationTests/History/HistoryTests.swift new file mode 100644 index 000000000..ebd8f202e --- /dev/null +++ b/Example/IntegrationTests/History/HistoryTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Combine +import XCTest +@testable import WalletConnectHistory + +final class HistoryTests: XCTestCase { + + var publishers = Set() + + let relayUrl = "wss://relay.walletconnect.com" + let historyUrl = "https://history.walletconnect.com" + + var relayClient1: RelayClient! + var relayClient2: RelayClient! + + var historyClient: HistoryNetworkService! + + override func setUp() { + let keychain1 = KeychainStorageMock() + let keychain2 = KeychainStorageMock() + relayClient1 = makeRelayClient(prefix: "🐄", keychain: keychain1) + relayClient2 = makeRelayClient(prefix: "🐫", keychain: keychain2) + historyClient = makeHistoryClient(keychain: keychain1) + } + + private func makeRelayClient(prefix: String, keychain: KeychainStorageProtocol) -> RelayClient { + return RelayClient( + relayHost: InputConfig.relayHost, + projectId: InputConfig.projectId, + keyValueStorage: RuntimeKeyValueStorage(), + keychainStorage: keychain, + socketFactory: DefaultSocketFactory(), + logger: ConsoleLogger(suffix: prefix + " [Relay]", loggingLevel: .debug)) + } + + private func makeHistoryClient(keychain: KeychainStorageProtocol) -> HistoryNetworkService { + let clientIdStorage = ClientIdStorage(keychain: keychain) + return HistoryNetworkService(clientIdStorage: clientIdStorage) + } + + func testRegister() async throws { + let payload = RegisterPayload(tags: ["7000"], relayUrl: relayUrl) + + try await historyClient.registerTags(payload: payload, historyUrl: historyUrl) + } + + func testGetMessages() async throws { + let exp = expectation(description: "Test Get Messages") + let tag = 7000 + let payload = "{}" + let agreement = AgreementPrivateKey() + let topic = agreement.publicKey.rawRepresentation.sha256().hex + + relayClient2.messagePublisher.sink { (topic, message, publishedAt) in + exp.fulfill() + }.store(in: &publishers) + + try await historyClient.registerTags( + payload: RegisterPayload(tags: [String(tag)], relayUrl: relayUrl), + historyUrl: historyUrl) + + try await relayClient2.subscribe(topic: topic) + try await relayClient1.publish(topic: topic, payload: payload, tag: tag, prompt: false, ttl: 3000) + + wait(for: [exp], timeout: InputConfig.defaultTimeout) + + sleep(5) // History server has a queue + + let messages = try await historyClient.getMessages( + payload: GetMessagesPayload( + topic: topic, + originId: nil, + messageCount: 200, + direction: .forward), + historyUrl: historyUrl) + + XCTAssertEqual(messages.messages, [payload]) + } +} diff --git a/Example/IntegrationTests/Sync/SyncDerivationServiceTests.swift b/Example/IntegrationTests/Sync/SyncDerivationServiceTests.swift new file mode 100644 index 000000000..549ed6929 --- /dev/null +++ b/Example/IntegrationTests/Sync/SyncDerivationServiceTests.swift @@ -0,0 +1,26 @@ +import Foundation +import XCTest +@testable import WalletConnectSync +@testable import WalletConnectSigner + +class SyncDerivationServiceTests: XCTestCase { + + func testDerivation() throws { + let account = Account("eip155:1:0x1FF34C90a0850Fe7227fcFA642688b9712477482")! + let signature = "0xc91265eadb1473d90f8d49d31b7016feb7f7761a2a986ca2146a4b8964f3357569869680154927596a5829ceea925f4196b8a853a29c2c1d5915832fc9f1c6a01c" + let keychain = KeychainStorageMock() + let syncStorage = SyncSignatureStore(keychain: keychain) + let kms = KeyManagementService(keychain: keychain) + let derivationService = SyncDerivationService( + syncStorage: syncStorage, + bip44: DefaultBIP44Provider(), + kms: kms + ) + + try syncStorage.saveSignature(signature, for: account) + + let topic = try derivationService.deriveTopic(account: account, store: "my-user-profile") + + XCTAssertEqual(topic, "741f8902d339c4c16f33fa598a6598b63e5ed125d761374511b2e06562b033eb") + } +} diff --git a/Example/IntegrationTests/Sync/SyncTests.swift b/Example/IntegrationTests/Sync/SyncTests.swift new file mode 100644 index 000000000..595052dd0 --- /dev/null +++ b/Example/IntegrationTests/Sync/SyncTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Combine +import XCTest +import Web3 +@testable import WalletConnectSync +@testable import WalletConnectSigner + +final class SyncTests: XCTestCase { + + struct TestObject: DatabaseObject { + let id: String + let value: String + + var databaseId: String { + return id + } + } + + var publishers = Set() + + var client1: SyncClient! + var client2: SyncClient! + + var indexStore1: SyncIndexStore! + var indexStore2: SyncIndexStore! + + var syncStore1: SyncStore! + var syncStore2: SyncStore! + + var signer: MessageSigner! + + let storeName = "SyncTests_store" + + var account: Account { + return Account("eip155:1:" + pk.address.hex(eip55: true))! + } + + let pk = try! EthereumPrivateKey() + + var privateKey: Data { + return Data(pk.rawPrivateKey) + } + + override func setUp() async throws { + indexStore1 = makeIndexStore() + indexStore2 = makeIndexStore() + client1 = makeClient(indexStore: indexStore1, suffix: "❤️") + client2 = makeClient(indexStore: indexStore2, suffix: "💜") + syncStore1 = makeSyncStore(client: client1, indexStore: indexStore1) + syncStore2 = makeSyncStore(client: client2, indexStore: indexStore2) + signer = MessageSignerFactory(signerFactory: DefaultSignerFactory()).create(projectId: InputConfig.projectId) + } + + func makeClient(indexStore: SyncIndexStore, suffix: String) -> SyncClient { + let syncSignatureStore = SyncSignatureStore(keychain: KeychainStorageMock()) + let keychain = KeychainStorageMock() + let kms = KeyManagementService(keychain: keychain) + let derivationService = SyncDerivationService(syncStorage: syncSignatureStore, bip44: DefaultBIP44Provider(), kms: kms) + let logger = ConsoleLogger(suffix: suffix, loggingLevel: .debug) + let relayClient = RelayClient(relayHost: InputConfig.relayHost, projectId: InputConfig.projectId, keychainStorage: keychain, socketFactory: DefaultSocketFactory(), logger: logger) + let networkingInteractor = NetworkingClientFactory.create( + relayClient: relayClient, + logger: logger, + keychainStorage: keychain, + keyValueStorage: RuntimeKeyValueStorage()) + let historyStore = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "historyStore") + let syncHistoryStore = SyncHistoryStore(store: historyStore) + let syncService = SyncService(networkInteractor: networkingInteractor, derivationService: derivationService, signatureStore: syncSignatureStore, indexStore: indexStore, historyStore: syncHistoryStore, logger: logger) + return SyncClient(syncService: syncService, syncSignatureStore: syncSignatureStore) + } + + func makeIndexStore() -> SyncIndexStore { + let store = CodableStore(defaults: RuntimeKeyValueStorage(), identifier: "indexStore") + return SyncIndexStore(store: store) + } + + func makeSyncStore(client: SyncClient, indexStore: SyncIndexStore) -> SyncStore { + let objectStore = KeyedDatabase(storage: RuntimeKeyValueStorage(), identifier: "objectStore") + return SyncStore(name: storeName, syncClient: client, indexStore: indexStore, objectStore: objectStore) + } + + func testSync() async throws { + let setExpectation = expectation(description: "syncSetTest") + let delExpectation = expectation(description: "syncDelTest") + + let object = TestObject(id: "id-1", value: "value-1") + + syncStore1.syncUpdatePublisher.sink { (_, _, update) in + switch update { + case .set: + XCTFail() + case .delete: + delExpectation.fulfill() + } + }.store(in: &publishers) + + syncStore2.syncUpdatePublisher.sink { (_, _, update) in + switch update { + case .set: + setExpectation.fulfill() + case .delete: + XCTFail() + } + }.store(in: &publishers) + + // Configure clients + + try await registerClient(client: client1) + try await registerClient(client: client2) + + // Testing SyncStore `set` + + try await syncStore1.set(object: object, for: account) + + wait(for: [setExpectation], timeout: InputConfig.defaultTimeout) + + XCTAssertEqual(try syncStore1.getAll(for: account), [object]) + XCTAssertEqual(try syncStore2.getAll(for: account), [object]) + + // Testing SyncStore `delete` + + try await syncStore2.delete(id: object.id, for: account) + + wait(for: [delExpectation], timeout: InputConfig.defaultTimeout) + + XCTAssertEqual(try syncStore1.getAll(for: account), []) + XCTAssertEqual(try syncStore2.getAll(for: account), []) + } + + private func registerClient(client: SyncClient) async throws { + let message = client.getMessage(account: account) + + let signature = try signer.sign(message: message, privateKey: privateKey, type: .eip191) + + try await client.register(account: account, signature: signature) + try await client.create(account: account, store: storeName) + } +} diff --git a/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift b/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift index 129ebb8a0..fd273e35a 100644 --- a/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift +++ b/Example/RelayIntegrationTests/RelayClientEndToEndTests.swift @@ -32,9 +32,9 @@ final class RelayClientEndToEndTests: XCTestCase { func makeRelayClient(prefix: String) -> RelayClient { let clientIdStorage = ClientIdStorage(keychain: KeychainStorageMock()) - let socketAuthenticator = SocketAuthenticator( + let socketAuthenticator = ClientIdAuthenticator( clientIdStorage: clientIdStorage, - relayHost: InputConfig.relayHost + url: InputConfig.relayUrl ) let urlFactory = RelayUrlFactory( relayHost: InputConfig.relayHost, @@ -101,18 +101,23 @@ final class RelayClientEndToEndTests: XCTestCase { relayB.messagePublisher.sink { topic, payload, _ in (subscriptionBTopic, subscriptionBPayload) = (topic, payload) + Task(priority: .high) { + sleep(1) + try await relayB.publish(topic: randomTopic, payload: payloadB, tag: 0, prompt: false, ttl: 60) + } expectationB.fulfill() }.store(in: &publishers) - relayA.socketConnectionStatusPublisher.sink { _ in + relayA.socketConnectionStatusPublisher.sink { status in + guard status == .connected else {return} Task(priority: .high) { - try await relayA.publish(topic: randomTopic, payload: payloadA, tag: 0, prompt: false, ttl: 60) try await relayA.subscribe(topic: randomTopic) + try await relayA.publish(topic: randomTopic, payload: payloadA, tag: 0, prompt: false, ttl: 60) } }.store(in: &publishers) - relayB.socketConnectionStatusPublisher.sink { _ in + relayB.socketConnectionStatusPublisher.sink { status in + guard status == .connected else {return} Task(priority: .high) { - try await relayB.publish(topic: randomTopic, payload: payloadB, tag: 0, prompt: false, ttl: 60) try await relayB.subscribe(topic: randomTopic) } }.store(in: &publishers) diff --git a/Example/Shared/DefaultBIP44Provider.swift b/Example/Shared/DefaultBIP44Provider.swift new file mode 100644 index 000000000..5aed02650 --- /dev/null +++ b/Example/Shared/DefaultBIP44Provider.swift @@ -0,0 +1,25 @@ +import Foundation +import Auth +import Web3 +import CryptoSwift +import HDWalletKit + +struct DefaultBIP44Provider: BIP44Provider { + + public func derive(entropy: Data, path: [WalletConnectSigner.DerivationPath]) -> Data { + let mnemonic = Mnemonic.create(entropy: entropy) + let seed = Mnemonic.createSeed(mnemonic: mnemonic) + let privateKey = PrivateKey(seed: seed, coin: .bitcoin) + + let derived = path.reduce(privateKey) { result, path in + switch path { + case .hardened(let index): + return result.derived(at: .hardened(index)) + case .notHardened(let index): + return result.derived(at: .notHardened(index)) + } + } + + return derived.raw + } +} diff --git a/Example/Shared/DefaultCryptoProvider.swift b/Example/Shared/DefaultCryptoProvider.swift index 79a11fe2c..4905855f7 100644 --- a/Example/Shared/DefaultCryptoProvider.swift +++ b/Example/Shared/DefaultCryptoProvider.swift @@ -2,6 +2,7 @@ import Foundation import Auth import Web3 import CryptoSwift +import HDWalletKit struct DefaultCryptoProvider: CryptoProvider { @@ -20,4 +21,21 @@ struct DefaultCryptoProvider: CryptoProvider { let hash = digest.calculate(for: [UInt8](data)) return Data(hash) } + + public func derive(entropy: Data, path: [WalletConnectSigner.DerivationPath]) -> Data { + let mnemonic = Mnemonic.create(entropy: entropy) + let seed = Mnemonic.createSeed(mnemonic: mnemonic) + let privateKey = PrivateKey(seed: seed, coin: .bitcoin) + + let derived = path.reduce(privateKey) { result, path in + switch path { + case .hardened(let index): + return result.derived(at: .hardened(index)) + case .notHardened(let index): + return result.derived(at: .notHardened(index)) + } + } + + return derived.raw + } } diff --git a/Example/Shared/Tests/InputConfig.swift b/Example/Shared/Tests/InputConfig.swift index 0a71a0bed..8a19855af 100644 --- a/Example/Shared/Tests/InputConfig.swift +++ b/Example/Shared/Tests/InputConfig.swift @@ -6,6 +6,10 @@ struct InputConfig { return config(for: "RELAY_HOST")! } + static var relayUrl: String { + return "wss://\(relayHost)" + } + static var projectId: String { return config(for: "PROJECT_ID")! } diff --git a/Example/Showcase/Classes/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift b/Example/Showcase/Classes/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift index fbd9ee1a7..c7734a2f1 100644 --- a/Example/Showcase/Classes/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift +++ b/Example/Showcase/Classes/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift @@ -1,19 +1,21 @@ import WalletConnectNetworking import WalletConnectPairing import Auth +import Web3Modal struct ThirdPartyConfigurator: Configurator { func configure() { + + let metadata = AppMetadata( + name: "Showcase App", + description: "Showcase description", + url: "example.wallet", + icons: ["https://avatars.githubusercontent.com/u/37784886"] + ) + Networking.configure(projectId: InputConfig.projectId, socketFactory: DefaultSocketFactory()) - Pair.configure( - metadata: AppMetadata( - name: "Showcase App", - description: "Showcase description", - url: "example.wallet", - icons: ["https://avatars.githubusercontent.com/u/37784886"] - )) - Auth.configure(crypto: DefaultCryptoProvider()) + Web3Modal.configure(projectId: InputConfig.projectId, metadata: metadata) } } diff --git a/Example/Showcase/Classes/DomainLayer/AccountStorage/AccountStorage.swift b/Example/Showcase/Classes/DomainLayer/AccountStorage/AccountStorage.swift index c8bfc44db..fafa8bc8b 100644 --- a/Example/Showcase/Classes/DomainLayer/AccountStorage/AccountStorage.swift +++ b/Example/Showcase/Classes/DomainLayer/AccountStorage/AccountStorage.swift @@ -24,15 +24,3 @@ final class AccountStorage { } } } - -private extension ImportAccount { - - var storageId: String { - switch self { - case .swift, .kotlin, .js: - return name - case .custom(let privateKey): - return privateKey - } - } -} diff --git a/Example/Showcase/Classes/DomainLayer/Chat/ChatService.swift b/Example/Showcase/Classes/DomainLayer/Chat/ChatService.swift index a55dc13b7..799726841 100644 --- a/Example/Showcase/Classes/DomainLayer/Chat/ChatService.swift +++ b/Example/Showcase/Classes/DomainLayer/Chat/ChatService.swift @@ -2,13 +2,14 @@ import Foundation import Combine import WalletConnectChat import WalletConnectRelay +import WalletConnectSign typealias Stream = AnyPublisher final class ChatService { - private lazy var client: ChatClient = { - Chat.configure() + private var client: ChatClient = { + Chat.configure(bip44: DefaultBIP44Provider()) return Chat.instance }() @@ -66,7 +67,7 @@ final class ChatService { } func setupSubscriptions(account: Account) { - client.setupSubscriptions(account: account) + try! client.setupSubscriptions(account: account) } func sendMessage(topic: String, message: String) async throws { @@ -81,7 +82,7 @@ final class ChatService { try await client.reject(inviteId: invite.id) } - func goPublic(account: Account, privateKey: String) async throws { + func goPublic(account: Account) async throws { try await client.goPublic(account: account) } @@ -91,17 +92,15 @@ final class ChatService { try await client.invite(invite: invite) } - func register(account: Account, privateKey: String) async throws { + func register(account: Account, importAccount: ImportAccount) async throws { _ = try await client.register(account: account) { message in - let signature = self.onSign(message: message, privateKey: privateKey) - return SigningResult.signed(signature) + return await self.onSign(message: message, importAccount: importAccount) } } - func unregister(account: Account, privateKey: String) async throws { + func unregister(account: Account, importAccount: ImportAccount) async throws { try await client.unregister(account: account) { message in - let signature = self.onSign(message: message, privateKey: privateKey) - return SigningResult.signed(signature) + return await self.onSign(message: message, importAccount: importAccount) } } @@ -116,9 +115,71 @@ final class ChatService { private extension ChatService { + func onSign(message: String, importAccount: ImportAccount) async -> SigningResult { + switch importAccount { + case .swift, .kotlin, .js, .custom: + return .signed(onSign(message: message, privateKey: importAccount.privateKey)) + case .web3Modal(let account, let topic): + return await onWeb3ModalSign(message: message, account: account, topic: topic) + } + } + func onSign(message: String, privateKey: String) -> CacaoSignature { let privateKey = Data(hex: privateKey) let signer = MessageSignerFactory(signerFactory: DefaultSignerFactory()).create() return try! signer.sign(message: message, privateKey: privateKey, type: .eip191) } + + func onWeb3ModalSign(message: String, account: Account, topic: String) async -> SigningResult { + guard let session = Sign.instance.getSessions().first(where: { $0.topic == topic }) else { return .rejected } + + do { + let request = makeRequest(session: session, message: message, account: account) + try await Sign.instance.request(params: request) + + let signature: CacaoSignature = try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = Sign.instance.sessionResponsePublisher + .sink { response in + defer { cancellable?.cancel() } + switch response.result { + case .response(let value): + do { + let string = try value.get(String.self) + let signature = CacaoSignature(t: .eip191, s: string.deleting0x()) + continuation.resume(returning: signature) + } catch { + continuation.resume(throwing: error) + } + case .error(let error): + continuation.resume(throwing: error) + } + } + } + + return .signed(signature) + } catch { + return .rejected + } + } + + func makeRequest(session: WalletConnectSign.Session, message: String, account: Account) -> Request { + return Request( + topic: session.topic, + method: "personal_sign", + params: AnyCodable(["0x" + message.data(using: .utf8)!.toHexString(), account.address]), + chainId: Blockchain("eip155:1")! + ) + } +} + +fileprivate extension String { + + func deleting0x() -> String { + var string = self + if starts(with: "0x") { + string.removeFirst(2) + } + return string + } } diff --git a/Example/Showcase/Classes/DomainLayer/Chat/ImportAccount.swift b/Example/Showcase/Classes/DomainLayer/Chat/ImportAccount.swift index 2c8ccffa0..508b70a69 100644 --- a/Example/Showcase/Classes/DomainLayer/Chat/ImportAccount.swift +++ b/Example/Showcase/Classes/DomainLayer/Chat/ImportAccount.swift @@ -1,68 +1,98 @@ import Foundation import Web3 +import WalletConnectSign -enum ImportAccount { +enum ImportAccount: Codable { case swift case kotlin case js case custom(privateKey: String) + case web3Modal(account: Account, topic: String) + + static let swiftId = "swift.eth" + static let kotlinId = "kotlin.eth" + static let jsId = "js.eth" + static let privateKeyId = "privateKey" + static let web3ModalId = "web3Modal" init?(input: String) { switch input.lowercased() { - case ImportAccount.swift.name: + case ImportAccount.swiftId: self = .swift - case ImportAccount.kotlin.name: + case ImportAccount.kotlinId: self = .kotlin - case ImportAccount.js.name: + case ImportAccount.jsId: self = .js default: - if let _ = try? EthereumPrivateKey(hexPrivateKey: "0x" + input, ctx: nil) { - self = .custom(privateKey: input) - } else if let _ = try? EthereumPrivateKey(hexPrivateKey: input, ctx: nil) { - self = .custom(privateKey: input.replacingOccurrences(of: "0x", with: "")) - } else { + switch true { + case input.starts(with: ImportAccount.privateKeyId): + if let _ = try? EthereumPrivateKey(hexPrivateKey: "0x" + input, ctx: nil) { + self = .custom(privateKey: input) + } else if let _ = try? EthereumPrivateKey(hexPrivateKey: input, ctx: nil) { + self = .custom(privateKey: input.replacingOccurrences(of: "0x", with: "")) + } else { + return nil + } + case input.starts(with: ImportAccount.web3ModalId): + let components = input.components(separatedBy: "-") + guard components.count == 3, let account = Account(components[1]) else { + return nil + } + self = .web3Modal(account: account, topic: components[2]) + default: return nil } } } - var name: String { + var storageId: String { switch self { case .swift: - return "swift.eth" + return ImportAccount.swiftId case .kotlin: - return "kotlin.eth" + return ImportAccount.kotlinId case .js: - return "js.eth" - case .custom: - return account.address + return ImportAccount.jsId + case .custom(let privateKey): + return "\(ImportAccount.privateKeyId)-\(privateKey)" + case .web3Modal(let account, let topic): + return "\(ImportAccount.web3ModalId)-\(account.absoluteString)-\(topic)" } } var account: Account { switch self { case .swift: - return Account("eip155:1:0x1AAe9864337E821f2F86b5D27468C59AA333C877")! + return Account("eip155:1:0x5F847B18b4a2Dd0F428796E89CaEe71480a2a98e")! case .kotlin: - return Account("eip155:1:0x4c0fb06CD854ab7D5909E830a5f49D184EB41BF5")! + return Account("eip155:1:0xC313B6F74FcB89147e751220184F0C56D37a210e")! case .js: - return Account("eip155:1:0x7ABa5B1F436e42f6d4A579FB3Ad6D204F6A91863")! + return Account("eip155:1:0x265F4Eb49ab95ED142C4995EF8B5FC9e57538836")! case .custom(let privateKey): - let address = try! EthereumPrivateKey(hexPrivateKey: "0x" + privateKey, ctx: nil).address.hex(eip55: false) + let address = try! EthereumPrivateKey(hexPrivateKey: "0x" + privateKey, ctx: nil).address.hex(eip55: true) return Account("eip155:1:\(address)")! + case .web3Modal(let account, _): + return account } } var privateKey: String { switch self { case .swift: - return "4dc0055d1831f7df8d855fc8cd9118f4a85ddc05395104c4cb0831a6752621a8" + return "85f52ec43821c1e2e24a248ee464e8d3f883e460acb0506e1eb6b520eb67ae15" case .kotlin: - return "ebe738a76b9a3b7457c3d5eca8d3d9ea6909bc563e05b6e0c5c35448f93100a0" + return "646a0ebac6bd34ba5f498b809148b2aca3793374cafe9dc417cf63bea80450bf" case .js: - return "de15cb11963e9bde0a5cce06a5ee2bda1cf3a67be6fbcd7a4fc8c0e4c4db0298" + return "8df6b8206eebcd3da89b750f1cf9bba887630c3c5eade83f44c06fa4f7cc5f65" case .custom(let privateKey): return privateKey + case .web3Modal: + fatalError("Private key not available") } } + + static func new() -> ImportAccount { + let key = try! EthereumPrivateKey() + return ImportAccount.custom(privateKey: key.rawPrivateKey.toHexString()) + } } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListInteractor.swift b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListInteractor.swift index 758b03fb7..0ddb01a9c 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListInteractor.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListInteractor.swift @@ -5,6 +5,10 @@ final class ChatListInteractor { private let chatService: ChatService private let accountStorage: AccountStorage + var account: Account? { + return accountStorage.importAccount?.account + } + init(chatService: ChatService, accountStorage: AccountStorage) { self.chatService = chatService self.accountStorage = accountStorage @@ -41,7 +45,7 @@ final class ChatListInteractor { func logout() async throws { guard let importAccount = accountStorage.importAccount else { return } try await chatService.goPrivate(account: importAccount.account) - try await chatService.unregister(account: importAccount.account, privateKey: importAccount.privateKey) + try await chatService.unregister(account: importAccount.account, importAccount: importAccount) accountStorage.importAccount = nil } } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListPresenter.swift b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListPresenter.swift index e44e516b8..56a3a0f9c 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListPresenter.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListPresenter.swift @@ -21,14 +21,12 @@ final class ChatListPresenter: ObservableObject { var receivedInviteViewModels: [InviteViewModel] { return receivedInvites - .filter { $0.status == .pending } .sorted(by: { $0.timestamp < $1.timestamp }) .map { InviteViewModel(invite: $0) } } var sentInviteViewModels: [InviteViewModel] { return sentInvites - .filter { $0.status == .pending } .sorted(by: { $0.timestamp < $1.timestamp }) .map { InviteViewModel(invite: $0) } } @@ -66,6 +64,14 @@ final class ChatListPresenter: ObservableObject { router.presentWelcome() } + @MainActor + func didCopyPress() async throws { + guard let account = interactor.account else { return } + UIPasteboard.general.string = account.absoluteString + + throw AlertError(message: "Account copied to clipboard") + } + func didPressNewChat() { presentInvite() } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListView.swift b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListView.swift index 3dc52a15f..979d1fe6d 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListView.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/ChatListView.swift @@ -36,6 +36,14 @@ struct ChatListView: View { } } + PlainButton { + try await presenter.didCopyPress() + } label: { + Text("Copy account") + .foregroundColor(.white) + } + .padding(.bottom, 16) + PlainButton { try await presenter.didLogoutPress() } label: { diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/Models/AlertError.swift b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/Models/AlertError.swift new file mode 100644 index 000000000..c7cc4f270 --- /dev/null +++ b/Example/Showcase/Classes/PresentationLayer/Chat/ChatList/Models/AlertError.swift @@ -0,0 +1,9 @@ +import Foundation + +struct AlertError: Error, LocalizedError { + let message: String + + var errorDescription: String? { + return message + } +} diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportInteractor.swift b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportInteractor.swift index 95c1cf133..c412afe30 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportInteractor.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportInteractor.swift @@ -13,6 +13,6 @@ final class ImportInteractor { } func register(importAccount: ImportAccount) async throws { - try await chatService.register(account: importAccount.account, privateKey: importAccount.privateKey) + try await chatService.register(account: importAccount.account, importAccount: importAccount) } } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportPresenter.swift b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportPresenter.swift index 9987116c2..6146979f3 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportPresenter.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportPresenter.swift @@ -1,5 +1,6 @@ import UIKit import Combine +import WalletConnectSign final class ImportPresenter: ObservableObject { @@ -18,16 +19,33 @@ final class ImportPresenter: ObservableObject { @MainActor func didPressWeb3Modal() async throws { router.presentWeb3Modal() + + let session: Session = try await withCheckedThrowingContinuation { continuation in + var cancellable: AnyCancellable? + cancellable = Sign.instance.sessionSettlePublisher.sink { session in + defer { cancellable?.cancel() } + return continuation.resume(returning: session) + } + } + + guard let account = session.accounts.first(where: { $0.blockchain.absoluteString == "eip155:1" }) else { + throw AlertError(message: "Тo matching accounts found in namespaces") + } + + try await importAccount(.web3Modal(account: account, topic: session.topic)) } @MainActor func didPressImport() async throws { - guard let importAccount = ImportAccount(input: input) + guard let account = ImportAccount(input: input) else { return input = .empty } + try await importAccount(account) + } - interactor.save(importAccount: importAccount) - try await interactor.register(importAccount: importAccount) - router.presentChat(importAccount: importAccount) + + func didPressRandom() async throws { + let account = ImportAccount.new() + try await importAccount(account) } } @@ -49,6 +67,18 @@ extension ImportPresenter: SceneViewModel { private extension ImportPresenter { func setupInitialState() { + Sign.instance.sessionSettlePublisher.sink { session in + Task(priority: .userInitiated) { + try await self.importAccount(.web3Modal(account: session.accounts.first!, topic: session.topic)) + } + + }.store(in: &disposeBag) + } + @MainActor + func importAccount(_ importAccount: ImportAccount) async throws { + try! await interactor.register(importAccount: importAccount) + interactor.save(importAccount: importAccount) + router.presentChat(importAccount: importAccount) } } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportRouter.swift b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportRouter.swift index 512dc95da..57b880cfc 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportRouter.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportRouter.swift @@ -13,16 +13,7 @@ final class ImportRouter { } func presentWeb3Modal() { - Web3ModalSheetController( - projectId: InputConfig.projectId, - metadata: AppMetadata( - name: "Showcase App", - description: "Showcase description", - url: "example.wallet", - icons: ["https://avatars.githubusercontent.com/u/37784886"] - ), - webSocketFactory: DefaultSocketFactory() - ).present(from: viewController) + Web3ModalSheetController().present(from: viewController) } func presentChat(importAccount: ImportAccount) { diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportView.swift b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportView.swift index 9de69d9f3..93954d6b5 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportView.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/Import/ImportView.swift @@ -26,6 +26,14 @@ struct ImportView: View { } } .padding(16.0) + + PlainButton { + try await presenter.didPressRandom() + } label: { + Text("Create new account") + .foregroundColor(.white) + } + .padding(.bottom, 16) } } } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomeInteractor.swift b/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomeInteractor.swift index 318dce292..cb76537c5 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomeInteractor.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomeInteractor.swift @@ -34,7 +34,7 @@ final class WelcomeInteractor { func goPublic() async throws { guard let importAccount = importAccount else { return } - try await chatService.goPublic(account: importAccount.account, privateKey: importAccount.privateKey) + try await chatService.goPublic(account: importAccount.account) } } diff --git a/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomePresenter.swift b/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomePresenter.swift index db4e676f4..2cf58ec21 100644 --- a/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomePresenter.swift +++ b/Example/Showcase/Classes/PresentationLayer/Chat/Welcome/WelcomePresenter.swift @@ -33,6 +33,8 @@ final class WelcomePresenter: ObservableObject { private extension WelcomePresenter { func setupInitialState() { - + interactor.trackConnection().sink { status in + print("Socket connection status: \(status)") + }.store(in: &disposeBag) } } diff --git a/Example/Showcase/Classes/PresentationLayer/Web3Inbox/Web3InboxViewController.swift b/Example/Showcase/Classes/PresentationLayer/Web3Inbox/Web3InboxViewController.swift index 05aef8a73..4a7a87c8f 100644 --- a/Example/Showcase/Classes/PresentationLayer/Web3Inbox/Web3InboxViewController.swift +++ b/Example/Showcase/Classes/PresentationLayer/Web3Inbox/Web3InboxViewController.swift @@ -18,7 +18,7 @@ final class Web3InboxViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - Web3Inbox.configure(account: importAccount.account, config: [.pushEnabled: false], onSign: onSing, environment: .sandbox) + Web3Inbox.configure(account: importAccount.account, bip44: DefaultBIP44Provider(), config: [.pushEnabled: false], environment: .sandbox, onSign: onSing) edgesForExtendedLayout = [] navigationItem.title = "Web3Inbox SDK" diff --git a/Example/SmokeTests.xctestplan b/Example/SmokeTests.xctestplan index 894a5d133..06e6e3467 100644 --- a/Example/SmokeTests.xctestplan +++ b/Example/SmokeTests.xctestplan @@ -44,6 +44,7 @@ "EIP191VerifierTests", "EIP55Tests", "ENSResolverTests", + "HistoryTests", "PairingTests", "PushTests", "PushTests\/testDappDeletePushSubscription()", @@ -70,7 +71,9 @@ "SignClientTests\/testSessionRequestFailureResponse()", "SignClientTests\/testSuccessfulSessionExtend()", "SignClientTests\/testSuccessfulSessionUpdateNamespaces()", - "SignerTest" + "SignerTest", + "SyncDerivationServiceTests", + "SyncTests" ], "target" : { "containerPath" : "container:ExampleApp.xcodeproj", diff --git a/Example/WalletApp/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift b/Example/WalletApp/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift index 58064d376..d90554a5d 100644 --- a/Example/WalletApp/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift +++ b/Example/WalletApp/ApplicationLayer/Configurator/ThirdPartyConfigurator.swift @@ -19,7 +19,13 @@ struct ThirdPartyConfigurator: Configurator { let account = Account(blockchain: Blockchain("eip155:1")!, address: EthKeyStore.shared.address)! - Web3Inbox.configure(account: account, config: [.chatEnabled: false, .settingsEnabled: false], onSign: Web3InboxSigner.onSing, environment: BuildConfiguration.shared.apnsEnvironment) + Web3Inbox.configure( + account: account, + bip44: DefaultBIP44Provider(), + config: [.chatEnabled: false, .settingsEnabled: false], + environment: BuildConfiguration.shared.apnsEnvironment, + onSign: Web3InboxSigner.onSing + ) } } diff --git a/Example/WalletApp/ApplicationLayer/SceneDelegate.swift b/Example/WalletApp/ApplicationLayer/SceneDelegate.swift index d6831aeb1..9744d8fa3 100644 --- a/Example/WalletApp/ApplicationLayer/SceneDelegate.swift +++ b/Example/WalletApp/ApplicationLayer/SceneDelegate.swift @@ -1,12 +1,10 @@ -import UIKit import Auth +import UIKit import WalletConnectPairing - final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private let app = Application() private var configurators: [Configurator] { @@ -17,19 +15,19 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { AppearanceConfigurator() ] } - + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - let sceneConfig: UISceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) - sceneConfig.delegateClass = SceneDelegate.self - return sceneConfig - } + let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + sceneConfig.delegateClass = SceneDelegate.self + return sceneConfig + } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } window = UIWindow(windowScene: windowScene) window?.makeKeyAndVisible() - + app.uri = connectionOptions.urlContexts.first?.url.absoluteString.replacingOccurrences(of: "walletapp://wc?uri=", with: "") configurators.configure() diff --git a/Example/WalletApp/Common/Helpers/QueryParameters.swift b/Example/WalletApp/Common/Helpers/QueryParameters.swift index 8e6c0c493..50527700a 100644 --- a/Example/WalletApp/Common/Helpers/QueryParameters.swift +++ b/Example/WalletApp/Common/Helpers/QueryParameters.swift @@ -1,20 +1,14 @@ -// -// QueryParameters.swift -// WalletApp -// -// Created by Aleksandr Maltsev on 24.05.2023. -// import Foundation extension URL { - var queryParameters: [AnyHashable: Any] { - let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) - guard let queryItems = urlComponents?.queryItems else { return [:] } - var queryParams: [AnyHashable: Any] = [:] - queryItems.forEach { - queryParams[$0.name] = $0.value - } - return queryParams - } + var queryParameters: [AnyHashable: Any] { + let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) + guard let queryItems = urlComponents?.queryItems else { return [:] } + var queryParams: [AnyHashable: Any] = [:] + queryItems.forEach { + queryParams[$0.name] = $0.value + } + return queryParams + } } diff --git a/Package.swift b/Package.swift index e1db6a6fd..9a61e0040 100644 --- a/Package.swift +++ b/Package.swift @@ -37,9 +37,15 @@ let package = Package( .library( name: "WalletConnectNetworking", targets: ["WalletConnectNetworking"]), + .library( + name: "WalletConnectSync", + targets: ["WalletConnectSync"]), .library( name: "WalletConnectVerify", targets: ["WalletConnectVerify"]), + .library( + name: "WalletConnectHistory", + targets: ["WalletConnectHistory"]), .library( name: "Web3Inbox", targets: ["Web3Inbox"]), @@ -58,7 +64,7 @@ let package = Package( path: "Sources/WalletConnectSign"), .target( name: "WalletConnectChat", - dependencies: ["WalletConnectIdentity", "WalletConnectSigner"], + dependencies: ["WalletConnectIdentity", "WalletConnectSync", "WalletConnectHistory"], path: "Sources/Chat"), .target( name: "Auth", @@ -88,6 +94,9 @@ let package = Package( .target( name: "WalletConnectPairing", dependencies: ["WalletConnectNetworking"]), + .target( + name: "WalletConnectHistory", + dependencies: ["HTTPClient", "WalletConnectRelay"]), .target( name: "Web3Inbox", dependencies: ["WalletConnectChat", "WalletConnectPush"]), @@ -124,6 +133,9 @@ let package = Package( .target( name: "Web3Modal", dependencies: ["QRCode", "WalletConnectSign"]), + .target( + name: "WalletConnectSync", + dependencies: ["WalletConnectSigner"]), .testTarget( name: "WalletConnectSignTests", dependencies: ["WalletConnectSign", "WalletConnectUtils", "TestingUtils", "WalletConnectVerify"]), diff --git a/Sources/Chat/Chat.swift b/Sources/Chat/Chat.swift index 9937d51ba..003b64335 100644 --- a/Sources/Chat/Chat.swift +++ b/Sources/Chat/Chat.swift @@ -11,7 +11,8 @@ public class Chat { return ChatClientFactory.create( keyserverUrl: keyserverUrl, relayClient: Relay.instance, - networkingInteractor: Networking.interactor + networkingInteractor: Networking.interactor, + syncClient: Sync.instance ) }() @@ -22,7 +23,12 @@ public class Chat { /// Chat instance config method /// - Parameters: /// - account: Chat initial account - static public func configure(keyserverUrl: String = "https://keys.walletconnect.com") { + /// - crypto: Crypto utils implementation + static public func configure( + keyserverUrl: String = "https://keys.walletconnect.com", + bip44: BIP44Provider + ) { Chat.keyserverUrl = keyserverUrl + Sync.configure(bip44: bip44) } } diff --git a/Sources/Chat/ChatClient.swift b/Sources/Chat/ChatClient.swift index 543bfbe1d..7d0f8ed5a 100644 --- a/Sources/Chat/ChatClient.swift +++ b/Sources/Chat/ChatClient.swift @@ -11,6 +11,7 @@ public class ChatClient { private let leaveService: LeaveService private let kms: KeyManagementService private let chatStorage: ChatStorage + private let syncRegisterService: SyncRegisterService public let socketConnectionStatusPublisher: AnyPublisher @@ -64,6 +65,7 @@ public class ChatClient { leaveService: LeaveService, kms: KeyManagementService, chatStorage: ChatStorage, + syncRegisterService: SyncRegisterService, socketConnectionStatusPublisher: AnyPublisher ) { self.identityClient = identityClient @@ -74,6 +76,7 @@ public class ChatClient { self.leaveService = leaveService self.kms = kms self.chatStorage = chatStorage + self.syncRegisterService = syncRegisterService self.socketConnectionStatusPublisher = socketConnectionStatusPublisher } @@ -90,6 +93,11 @@ public class ChatClient { ) async throws -> String { let publicKey = try await identityClient.register(account: account, onSign: onSign) + if !syncRegisterService.isRegistered(account: account) { + try await chatStorage.initializeHistory(account: account) + try await syncRegisterService.register(account: account, onSign: onSign) + } + guard !isPrivate else { return publicKey } @@ -128,6 +136,7 @@ public class ChatClient { public func goPrivate(account: Account) async throws { let inviteKey = try await identityClient.goPrivate(account: account) resubscriptionService.unsubscribeFromInvites(inviteKey: inviteKey) + try await chatStorage.removeInviteKey(inviteKey, account: account) } /// Registers an invite key if not yet registered on this client from keyserver @@ -137,6 +146,9 @@ public class ChatClient { public func goPublic(account: Account) async throws { let inviteKey = try await identityClient.goPublic(account: account) try await resubscriptionService.subscribeForInvites(inviteKey: inviteKey) + try await chatStorage.initializeStores(for: account) + try await chatStorage.initializeDelegates() + try await chatStorage.setInviteKey(inviteKey, account: account) } /// Accepts a chat invite by id from account specified as inviteeAccount in Invite @@ -189,7 +201,7 @@ public class ChatClient { return chatStorage.getMessages(topic: topic) } - public func setupSubscriptions(account: Account) { - chatStorage.setupSubscriptions(account: account) + public func setupSubscriptions(account: Account) throws { + try chatStorage.setupSubscriptions(account: account) } } diff --git a/Sources/Chat/ChatClientFactory.swift b/Sources/Chat/ChatClientFactory.swift index a36b2cac5..c304954c0 100644 --- a/Sources/Chat/ChatClientFactory.swift +++ b/Sources/Chat/ChatClientFactory.swift @@ -2,7 +2,7 @@ import Foundation public struct ChatClientFactory { - static func create(keyserverUrl: String, relayClient: RelayClient, networkingInteractor: NetworkingInteractor) -> ChatClient { + static func create(keyserverUrl: String, relayClient: RelayClient, networkingInteractor: NetworkingInteractor, syncClient: SyncClient) -> ChatClient { let keychain = KeychainStorage(serviceIdentifier: "com.walletconnect.sdk") let keyserverURL = URL(string: keyserverUrl)! return ChatClientFactory.create( @@ -11,7 +11,8 @@ public struct ChatClientFactory { networkingInteractor: networkingInteractor, keychain: keychain, logger: ConsoleLogger(loggingLevel: .debug), - keyValueStorage: UserDefaults.standard + storage: UserDefaults.standard, + syncClient: syncClient ) } @@ -21,20 +22,31 @@ public struct ChatClientFactory { networkingInteractor: NetworkingInteractor, keychain: KeychainStorageProtocol, logger: ConsoleLogging, - keyValueStorage: KeyValueStorage + storage: KeyValueStorage, + syncClient: SyncClient ) -> ChatClient { + let historyClient = HistoryClientFactory.create(keychain: keychain) let kms = KeyManagementService(keychain: keychain) - let messageStore = KeyedDatabase(storage: keyValueStorage, identifier: ChatStorageIdentifiers.messages.rawValue) - let receivedInviteStore = KeyedDatabase(storage: keyValueStorage, identifier: ChatStorageIdentifiers.receivedInvites.rawValue) - let sentInviteStore = KeyedDatabase(storage: keyValueStorage, identifier: ChatStorageIdentifiers.sentInvites.rawValue) - let threadStore = KeyedDatabase(storage: keyValueStorage, identifier: ChatStorageIdentifiers.threads.rawValue) - let chatStorage = ChatStorage(messageStore: messageStore, receivedInviteStore: receivedInviteStore, sentInviteStore: sentInviteStore, threadStore: threadStore) - let resubscriptionService = ResubscriptionService(networkingInteractor: networkingInteractor, kms: kms, chatStorage: chatStorage, logger: logger) + let serializer = Serializer(kms: kms) + let historyService = HistoryService(historyClient: historyClient, seiralizer: serializer) + let messageStore = KeyedDatabase(storage: storage, identifier: ChatStorageIdentifiers.messages.rawValue) + let receivedInviteStore = KeyedDatabase(storage: storage, identifier: ChatStorageIdentifiers.receivedInvites.rawValue) + let threadStore: SyncStore = SyncStoreFactory.create(name: ChatStorageIdentifiers.thread.rawValue, syncClient: syncClient, storage: storage) let identityClient = IdentityClientFactory.create(keyserver: keyserverURL, keychain: keychain, logger: logger) + let inviteKeyDelegate = InviteKeyDelegate(networkingInteractor: networkingInteractor, kms: kms, identityClient: identityClient) + let sentInviteDelegate = SentInviteStoreDelegate(networkingInteractor: networkingInteractor, kms: kms) + let threadDelegate = ThreadStoreDelegate(networkingInteractor: networkingInteractor, kms: kms, historyService: historyService) + let sentInviteStore: SyncStore = SyncStoreFactory.create(name: ChatStorageIdentifiers.sentInvite.rawValue, syncClient: syncClient, storage: storage) + let inviteKeyStore: SyncStore = SyncStoreFactory.create(name: ChatStorageIdentifiers.inviteKey.rawValue, syncClient: syncClient, storage: storage) + let receivedInviteStatusStore: SyncStore = SyncStoreFactory.create(name: ChatStorageIdentifiers.receivedInviteStatus.rawValue, syncClient: syncClient, storage: storage) + let receivedInviteStatusDelegate = ReceiviedInviteStatusDelegate() + let chatStorage = ChatStorage(kms: kms, messageStore: messageStore, receivedInviteStore: receivedInviteStore, sentInviteStore: sentInviteStore, threadStore: threadStore, inviteKeyStore: inviteKeyStore, receivedInviteStatusStore: receivedInviteStatusStore, historyService: historyService, sentInviteStoreDelegate: sentInviteDelegate, threadStoreDelegate: threadDelegate, inviteKeyDelegate: inviteKeyDelegate, receiviedInviteStatusDelegate: receivedInviteStatusDelegate) + let resubscriptionService = ResubscriptionService(networkingInteractor: networkingInteractor, kms: kms, logger: logger) let invitationHandlingService = InvitationHandlingService(keyserverURL: keyserverURL, networkingInteractor: networkingInteractor, identityClient: identityClient, kms: kms, logger: logger, chatStorage: chatStorage) let inviteService = InviteService(keyserverURL: keyserverURL, networkingInteractor: networkingInteractor, identityClient: identityClient, kms: kms, chatStorage: chatStorage, logger: logger) let leaveService = LeaveService() let messagingService = MessagingService(keyserverURL: keyserverURL, networkingInteractor: networkingInteractor, identityClient: identityClient, chatStorage: chatStorage, logger: logger) + let syncRegisterService = SyncRegisterService(syncClient: syncClient) let client = ChatClient( identityClient: identityClient, @@ -45,6 +57,7 @@ public struct ChatClientFactory { leaveService: leaveService, kms: kms, chatStorage: chatStorage, + syncRegisterService: syncRegisterService, socketConnectionStatusPublisher: relayClient.socketConnectionStatusPublisher ) diff --git a/Sources/Chat/ChatImports.swift b/Sources/Chat/ChatImports.swift index 9af0db974..447ee4a25 100644 --- a/Sources/Chat/ChatImports.swift +++ b/Sources/Chat/ChatImports.swift @@ -1,4 +1,6 @@ #if !CocoaPods @_exported import WalletConnectSigner @_exported import WalletConnectIdentity +@_exported import WalletConnectSync +@_exported import WalletConnectHistory #endif diff --git a/Sources/Chat/ChatStorage.swift b/Sources/Chat/ChatStorage.swift deleted file mode 100644 index c26ed3e1f..000000000 --- a/Sources/Chat/ChatStorage.swift +++ /dev/null @@ -1,193 +0,0 @@ -import Foundation -import Combine - -final class ChatStorage { - - private let messageStore: KeyedDatabase - private let receivedInviteStore: KeyedDatabase - private let sentInviteStore: KeyedDatabase - private let threadStore: KeyedDatabase - - private var messagesPublisherSubject = PassthroughSubject<[Message], Never>() - private var receivedInvitesPublisherSubject = PassthroughSubject<[ReceivedInvite], Never>() - private var sentInvitesPublisherSubject = PassthroughSubject<[SentInvite], Never>() - private var threadsPublisherSubject = PassthroughSubject<[Thread], Never>() - - private var newMessagePublisherSubject = PassthroughSubject() - private var newReceivedInvitePublisherSubject = PassthroughSubject() - private var newSentInvitePublisherSubject = PassthroughSubject() - private var newThreadPublisherSubject = PassthroughSubject() - - private var acceptPublisherSubject = PassthroughSubject<(String, SentInvite), Never>() - private var rejectPublisherSubject = PassthroughSubject<(SentInvite), Never>() - - var messagesPublisher: AnyPublisher<[Message], Never> { - messagesPublisherSubject.eraseToAnyPublisher() - } - - var receivedInvitesPublisher: AnyPublisher<[ReceivedInvite], Never> { - receivedInvitesPublisherSubject.eraseToAnyPublisher() - } - - var sentInvitesPublisher: AnyPublisher<[SentInvite], Never> { - sentInvitesPublisherSubject.eraseToAnyPublisher() - } - - var threadsPublisher: AnyPublisher<[Thread], Never> { - threadsPublisherSubject.eraseToAnyPublisher() - } - - var newMessagePublisher: AnyPublisher { - newMessagePublisherSubject.eraseToAnyPublisher() - } - - var newReceivedInvitePublisher: AnyPublisher { - newReceivedInvitePublisherSubject.eraseToAnyPublisher() - } - - var newSentInvitePublisher: AnyPublisher { - newSentInvitePublisherSubject.eraseToAnyPublisher() - } - - var newThreadPublisher: AnyPublisher { - newThreadPublisherSubject.eraseToAnyPublisher() - } - - var acceptPublisher: AnyPublisher<(String, SentInvite), Never> { - acceptPublisherSubject.eraseToAnyPublisher() - } - - var rejectPublisher: AnyPublisher { - rejectPublisherSubject.eraseToAnyPublisher() - } - - init( - messageStore: KeyedDatabase, - receivedInviteStore: KeyedDatabase, - sentInviteStore: KeyedDatabase, - threadStore: KeyedDatabase - ) { - self.messageStore = messageStore - self.receivedInviteStore = receivedInviteStore - self.sentInviteStore = sentInviteStore - self.threadStore = threadStore - } - - func setupSubscriptions(account: Account) { - messageStore.onUpdate = { [unowned self] in - messagesPublisherSubject.send(getMessages(account: account)) - } - receivedInviteStore.onUpdate = { [unowned self] in - receivedInvitesPublisherSubject.send(getReceivedInvites(account: account)) - } - sentInviteStore.onUpdate = { [unowned self] in - sentInvitesPublisherSubject.send(getSentInvites(account: account)) - } - threadStore.onUpdate = { [unowned self] in - threadsPublisherSubject.send(getThreads(account: account)) - } - } - - // MARK: - Invites - - func getReceivedInvite(id: Int64) -> ReceivedInvite? { - return receivedInviteStore.getAll() - .first(where: { $0.id == id }) - } - - func getSentInvite(id: Int64) -> SentInvite? { - return sentInviteStore.getAll() - .first(where: { $0.id == id }) - } - - func set(receivedInvite: ReceivedInvite, account: Account) { - receivedInviteStore.set(receivedInvite, for: account.absoluteString) - newReceivedInvitePublisherSubject.send(receivedInvite) - } - - func set(sentInvite: SentInvite, account: Account) { - sentInviteStore.set(sentInvite, for: account.absoluteString) - newSentInvitePublisherSubject.send(sentInvite) - } - - func getReceivedInvites(account: Account) -> [ReceivedInvite] { - return receivedInviteStore.getElements(for: account.absoluteString) - } - - func getSentInvites(account: Account) -> [SentInvite] { - return sentInviteStore.getElements(for: account.absoluteString) - } - - func accept(receivedInvite: ReceivedInvite, account: Account) { - receivedInviteStore.delete(receivedInvite, for: account.absoluteString) - - let accepted = ReceivedInvite(invite: receivedInvite, status: .approved) - receivedInviteStore.set(accepted, for: account.absoluteString) - } - - func reject(receivedInvite: ReceivedInvite, account: Account) { - receivedInviteStore.delete(receivedInvite, for: account.absoluteString) - - let rejected = ReceivedInvite(invite: receivedInvite, status: .rejected) - receivedInviteStore.set(rejected, for: account.absoluteString) - } - - func accept(sentInviteId: Int64, account: Account, topic: String) { - guard let invite = getSentInvite(id: sentInviteId) - else { return } - - sentInviteStore.delete(invite, for: account.absoluteString) - - let approved = SentInvite(invite: invite, status: .approved) - sentInviteStore.set(approved, for: account.absoluteString) - - acceptPublisherSubject.send((topic, approved)) - } - - func reject(sentInviteId: Int64, account: Account) { - guard let invite = getSentInvite(id: sentInviteId) - else { return } - - sentInviteStore.delete(invite, for: account.absoluteString) - - let rejected = SentInvite(invite: invite, status: .rejected) - // TODO: Update also for peer invites - sentInviteStore.set(rejected, for: account.absoluteString) - - rejectPublisherSubject.send(rejected) - } - - // MARK: - Threads - - func getAllThreads() -> [Thread] { - return threadStore.getAll() - } - - func getThreads(account: Account) -> [Thread] { - return threadStore.getElements(for: account.absoluteString) - } - - func getThread(topic: String) -> Thread? { - return getAllThreads().first(where: { $0.topic == topic }) - } - - func set(thread: Thread, account: Account) { - threadStore.set(thread, for: account.absoluteString) - newThreadPublisherSubject.send(thread) - } - - // MARK: - Messages - - func set(message: Message, account: Account) { - messageStore.set(message, for: account.absoluteString) - newMessagePublisherSubject.send(message) - } - - func getMessages(topic: String) -> [Message] { - return messageStore.getAll().filter { $0.topic == topic } - } - - func getMessages(account: Account) -> [Message] { - return messageStore.getElements(for: account.absoluteString) - } -} diff --git a/Sources/Chat/ChatStorageIdentifiers.swift b/Sources/Chat/ChatStorageIdentifiers.swift index 2194a3986..f20d9f2e1 100644 --- a/Sources/Chat/ChatStorageIdentifiers.swift +++ b/Sources/Chat/ChatStorageIdentifiers.swift @@ -3,7 +3,10 @@ import Foundation enum ChatStorageIdentifiers: String { case topicToInvitationPubKey = "com.walletconnect.chat.topicToInvitationPubKey" case messages = "com.walletconnect.chat.messages" - case threads = "com.walletconnect.chat.threads" case receivedInvites = "com.walletconnect.chat.receivedInvites" - case sentInvites = "com.walletconnect.chat.sentInvites" + + case thread = "com.walletconnect.chat.threads" + case sentInvite = "com.walletconnect.chat.sentInvites" + case inviteKey = "com.walletconnect.chat.inviteKeys" + case receivedInviteStatus = "com.walletconnect.chat.receivedInviteStatuses" } diff --git a/Sources/Chat/KeyedDatabase.swift b/Sources/Chat/KeyedDatabase.swift deleted file mode 100644 index 9755542fc..000000000 --- a/Sources/Chat/KeyedDatabase.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation - -class KeyedDatabase where Element: Codable & Equatable { - - private var index: [String: [Element]] = [:] { - didSet { - guard oldValue != index else { return } - set(index, for: identifier) - onUpdate?() - } - } - - private let storage: KeyValueStorage - private let identifier: String - - var onUpdate: (() -> Void)? - - init(storage: KeyValueStorage, identifier: String) { - self.storage = storage - self.identifier = identifier - - initializeIndex() - } - - func getAll() -> [Element] { - return index.values.reduce([], +) - } - - func getElements(for key: String) -> [Element] { - return index[key] ?? [] - } - - func set(_ element: Element, for key: String) { - index.append(element, for: key) - } - - func delete(_ element: Element, for key: String) { - index.delete(element, for: key) - } -} - -private extension KeyedDatabase { - - func initializeIndex() { - guard - let data = storage.object(forKey: identifier) as? Data, - let decoded = try? JSONDecoder().decode([String: [Element]].self, from: data) - else { return } - - index = decoded - } - - func set(_ value: [String: [Element]], for key: String) { - let data = try! JSONEncoder().encode(value) - storage.set(data, forKey: key) - } -} diff --git a/Sources/Chat/ProtocolServices/Common/MessagingService.swift b/Sources/Chat/ProtocolServices/Common/MessagingService.swift index 2e330ec05..f032b3b97 100644 --- a/Sources/Chat/ProtocolServices/Common/MessagingService.swift +++ b/Sources/Chat/ProtocolServices/Common/MessagingService.swift @@ -82,6 +82,8 @@ private extension MessagingService { guard let (message, messageClaims) = try? MessagePayload.decodeAndVerify(from: payload.request) else { fatalError() /* TODO: Handle error */ } + // TODO: Compare message hash + Task(priority: .high) { let authorAccount = try await identityClient.resolveIdentity(iss: messageClaims.iss) diff --git a/Sources/Chat/ProtocolServices/Common/ResubscriptionService.swift b/Sources/Chat/ProtocolServices/Common/ResubscriptionService.swift index b2f87f7b6..215a2c8bd 100644 --- a/Sources/Chat/ProtocolServices/Common/ResubscriptionService.swift +++ b/Sources/Chat/ProtocolServices/Common/ResubscriptionService.swift @@ -5,33 +5,16 @@ class ResubscriptionService { private let networkingInteractor: NetworkInteracting private let kms: KeyManagementServiceProtocol private let logger: ConsoleLogging - private var chatStorage: ChatStorage private var publishers = [AnyCancellable]() init( networkingInteractor: NetworkInteracting, kms: KeyManagementServiceProtocol, - chatStorage: ChatStorage, logger: ConsoleLogging ) { self.networkingInteractor = networkingInteractor self.kms = kms self.logger = logger - self.chatStorage = chatStorage - - setUpResubscription() - } - - func setUpResubscription() { - networkingInteractor.socketConnectionStatusPublisher - .sink { [unowned self] status in - guard status == .connected else { return } - - Task(priority: .high) { - let topics = chatStorage.getAllThreads().map { $0.topic } - try await networkingInteractor.batchSubscribe(topics: topics) - } - }.store(in: &publishers) } func subscribeForInvites(inviteKey: AgreementPublicKey) async throws { diff --git a/Sources/Chat/ProtocolServices/History/HistoryService.swift b/Sources/Chat/ProtocolServices/History/HistoryService.swift new file mode 100644 index 000000000..ebad3a50d --- /dev/null +++ b/Sources/Chat/ProtocolServices/History/HistoryService.swift @@ -0,0 +1,37 @@ +import Foundation + +final class HistoryService { + + private let historyClient: HistoryClient + private let seiralizer: Serializing + + init(historyClient: HistoryClient, seiralizer: Serializing) { + self.historyClient = historyClient + self.seiralizer = seiralizer + } + + func register() async throws { + try await historyClient.register(tags: ["2002"]) + } + + func fetchMessageHistory(thread: Thread) async throws -> [Message] { + let wrappers: [MessagePayload.Wrapper] = try await historyClient.getMessages( + topic: thread.topic, + count: 200, direction: .backward + ) + + return wrappers.map { wrapper in + let (messagePayload, messageClaims) = try! MessagePayload.decodeAndVerify(from: wrapper) + + let authorAccount = messagePayload.recipientAccount == thread.selfAccount + ? thread.peerAccount + : thread.selfAccount + + return Message( + topic: thread.topic, + message: messagePayload.message, + authorAccount: authorAccount, + timestamp: messageClaims.iat) + } + } +} diff --git a/Sources/Chat/ProtocolServices/Invitee/InvitationHandlingService.swift b/Sources/Chat/ProtocolServices/Invitee/InvitationHandlingService.swift index f35fbd3bd..6226f8704 100644 --- a/Sources/Chat/ProtocolServices/Invitee/InvitationHandlingService.swift +++ b/Sources/Chat/ProtocolServices/Invitee/InvitationHandlingService.swift @@ -32,7 +32,8 @@ class InvitationHandlingService { guard let invite = chatStorage.getReceivedInvite(id: inviteId) else { throw Errors.inviteForIdNotFound } - let inviteePublicKey = try identityClient.getInviteKey(for: invite.inviteeAccount) + let inviteePublicKeyHex = try DIDKey(did: invite.inviteePublicKey).hexString + let inviteePublicKey = try AgreementPublicKey(hex: inviteePublicKeyHex) let inviterPublicKey = try DIDKey(did: invite.inviterPublicKey).hexString let symmetricKey = try kms.performKeyAgreement(selfPublicKey: inviteePublicKey, peerPublicKey: inviterPublicKey) @@ -66,10 +67,12 @@ class InvitationHandlingService { let thread = Thread( topic: threadTopic, selfAccount: invite.inviteeAccount, - peerAccount: invite.inviterAccount + peerAccount: invite.inviterAccount, + symKey: threadSymmetricKey.sharedKey.hexRepresentation ) - chatStorage.set(thread: thread, account: invite.inviteeAccount) + try await chatStorage.set(thread: thread, account: invite.inviteeAccount) + chatStorage.accept(receivedInvite: invite, account: invite.inviteeAccount) return thread.topic @@ -94,6 +97,8 @@ class InvitationHandlingService { ) chatStorage.reject(receivedInvite: invite, account: invite.inviteeAccount) + + try await chatStorage.syncRejectedReceivedInviteStatus(id: inviteId, account: invite.inviteeAccount) } } @@ -114,7 +119,7 @@ private extension InvitationHandlingService { Task(priority: .high) { let inviterAccount = try await identityClient.resolveIdentity(iss: claims.iss) // TODO: Should we cache it? - let inviteePublicKey = try await identityClient.resolveInvite(account: inviterAccount) + let inviteePublicKey = try await identityClient.resolveInvite(account: invite.inviteeAccount) let inviterPublicKey = invite.inviterPublicKey.did(variant: .X25519) let invite = ReceivedInvite( diff --git a/Sources/Chat/ProtocolServices/Inviter/InviteService.swift b/Sources/Chat/ProtocolServices/Inviter/InviteService.swift index 4a5797d7e..f1f09d31f 100644 --- a/Sources/Chat/ProtocolServices/Inviter/InviteService.swift +++ b/Sources/Chat/ProtocolServices/Inviter/InviteService.swift @@ -30,35 +30,38 @@ class InviteService { @discardableResult func invite(invite: Invite) async throws -> Int64 { - // TODO ad storage let protocolMethod = ChatInviteProtocolMethod() + let selfPubKeyY = try kms.createX25519KeyPair() + let selfPrivKeyY = try kms.getPrivateKey(for: selfPubKeyY)! + let inviteePublicKey = try DIDKey(did: invite.inviteePublicKey) - let symKeyI = try kms.performKeyAgreement(selfPublicKey: selfPubKeyY, peerPublicKey: inviteePublicKey.hexString) - let inviteTopic = try AgreementPublicKey(hex: inviteePublicKey.hexString).rawRepresentation.sha256().toHexString() - // overrides on invite toipic + let symKeyI = try kms.performKeyAgreement( + selfPublicKey: selfPubKeyY, + peerPublicKey: inviteePublicKey.hexString + ) + + let pubKeyX = try AgreementPublicKey(hex: inviteePublicKey.hexString) + let inviteTopic = pubKeyX.rawRepresentation.sha256().toHexString() + let responseTopic = symKeyI.derivedTopic() + try kms.setSymmetricKey(symKeyI.sharedKey, for: inviteTopic) + try kms.setSymmetricKey(symKeyI.sharedKey, for: responseTopic) - let payload = InvitePayload( - keyserver: keyserverURL, - message: invite.message, - inviteeAccount: invite.inviteeAccount, - inviterPublicKey: DIDKey(rawData: selfPubKeyY.rawRepresentation) - ) let wrapper = try identityClient.signAndCreateWrapper( - payload: payload, + payload: InvitePayload( + keyserver: keyserverURL, + message: invite.message, + inviteeAccount: invite.inviteeAccount, + inviterPublicKey: DIDKey(rawData: selfPubKeyY.rawRepresentation) + ), account: invite.inviterAccount ) let inviteId = RPCID() let request = RPCRequest(method: protocolMethod.method, params: wrapper, rpcid: inviteId) - // 2. Proposer subscribes to topic R which is the hash of the derived symKey - let responseTopic = symKeyI.derivedTopic() - - try kms.setSymmetricKey(symKeyI.sharedKey, for: responseTopic) - try await networkingInteractor.subscribe(topic: responseTopic) try await networkingInteractor.request(request, topic: inviteTopic, protocolMethod: protocolMethod, envelopeType: .type1(pubKey: selfPubKeyY.rawRepresentation)) @@ -67,10 +70,14 @@ class InviteService { message: invite.message, inviterAccount: invite.inviterAccount, inviteeAccount: invite.inviteeAccount, + inviterPubKeyY: selfPubKeyY.hexRepresentation, + inviterPrivKeyY: selfPrivKeyY.rawRepresentation.toHexString(), + responseTopic: responseTopic, + symKey: symKeyI.sharedKey.hexRepresentation, timestamp: Date().millisecondsSince1970 ) - chatStorage.set(sentInvite: sentInvite, account: invite.inviterAccount) + try await chatStorage.set(sentInvite: sentInvite, account: invite.inviterAccount) logger.debug("invite sent on topic: \(inviteTopic)") @@ -100,6 +107,14 @@ private extension InviteService { ) } }.store(in: &publishers) + + networkingInteractor.responseErrorSubscription(on: ChatInviteProtocolMethod()) + .sink { [unowned self] (payload: ResponseSubscriptionErrorPayload) in + + Task(priority: .high) { + try await chatStorage.reject(sentInviteId: payload.id.integer) + } + }.store(in: &publishers) } func createThread(sentInviteId: Int64, selfPubKeyHex: String, peerPubKey: DIDKey, account: Account, peerAccount: Account) async throws { @@ -113,13 +128,12 @@ private extension InviteService { let thread = Thread( topic: threadTopic, selfAccount: account, - peerAccount: peerAccount + peerAccount: peerAccount, + symKey: agreementKeys.sharedKey.hexRepresentation ) - chatStorage.set(thread: thread, account: account) - - // TODO: Implement reject for sentInvite - chatStorage.accept(sentInviteId: sentInviteId, account: account, topic: threadTopic) + try await chatStorage.set(thread: thread, account: account) + try await chatStorage.accept(sentInviteId: sentInviteId, topic: threadTopic) // TODO - remove symKeyI } diff --git a/Sources/Chat/ProtocolServices/Sync/SyncRegisterService.swift b/Sources/Chat/ProtocolServices/Sync/SyncRegisterService.swift new file mode 100644 index 000000000..8bffdfbbe --- /dev/null +++ b/Sources/Chat/ProtocolServices/Sync/SyncRegisterService.swift @@ -0,0 +1,32 @@ +import Foundation + +final class SyncRegisterService { + + private let syncClient: SyncClient + + init(syncClient: SyncClient) { + self.syncClient = syncClient + } + + func register(account: Account, onSign: @escaping SigningCallback) async throws { + let message = syncClient.getMessage(account: account) + + switch await onSign(message) { + case .signed(let signature): + try await syncClient.register(account: account, signature: signature) + case .rejected: + throw Errors.signatureRejected + } + } + + func isRegistered(account: Account) -> Bool { + return syncClient.isRegistered(account: account) + } +} + +private extension SyncRegisterService { + + enum Errors: Error { + case signatureRejected + } +} diff --git a/Sources/Chat/Storage/ChatStorage.swift b/Sources/Chat/Storage/ChatStorage.swift new file mode 100644 index 000000000..da0afe8a6 --- /dev/null +++ b/Sources/Chat/Storage/ChatStorage.swift @@ -0,0 +1,317 @@ +import Foundation +import Combine + +final class ChatStorage { + + private var publishers = Set() + + private let kms: KeyManagementServiceProtocol + private let messageStore: KeyedDatabase + private let receivedInviteStore: KeyedDatabase + private let sentInviteStore: SyncStore + private let threadStore: SyncStore + private let inviteKeyStore: SyncStore + private let receivedInviteStatusStore: SyncStore + private let historyService: HistoryService + + private let sentInviteStoreDelegate: SentInviteStoreDelegate + private let threadStoreDelegate: ThreadStoreDelegate + private let inviteKeyDelegate: InviteKeyDelegate + private let receiviedInviteStatusDelegate: ReceiviedInviteStatusDelegate + + private var messagesPublisherSubject = PassthroughSubject<[Message], Never>() + private var receivedInvitesPublisherSubject = PassthroughSubject<[ReceivedInvite], Never>() + private var newMessagePublisherSubject = PassthroughSubject() + private var newReceivedInvitePublisherSubject = PassthroughSubject() + private var newSentInvitePublisherSubject = PassthroughSubject() + private var newThreadPublisherSubject = PassthroughSubject() + + private var acceptPublisherSubject = PassthroughSubject<(String, SentInvite), Never>() + private var rejectPublisherSubject = PassthroughSubject<(SentInvite), Never>() + + var messagesPublisher: AnyPublisher<[Message], Never> { + messagesPublisherSubject.eraseToAnyPublisher() + } + + var receivedInvitesPublisher: AnyPublisher<[ReceivedInvite], Never> { + receivedInvitesPublisherSubject.eraseToAnyPublisher() + } + + var sentInvitesPublisher: AnyPublisher<[SentInvite], Never> { + sentInviteStore.dataUpdatePublisher + } + + var threadsPublisher: AnyPublisher<[Thread], Never> { + threadStore.dataUpdatePublisher + } + + var newMessagePublisher: AnyPublisher { + newMessagePublisherSubject.eraseToAnyPublisher() + } + + var newReceivedInvitePublisher: AnyPublisher { + newReceivedInvitePublisherSubject.eraseToAnyPublisher() + } + + var newSentInvitePublisher: AnyPublisher { + newSentInvitePublisherSubject.eraseToAnyPublisher() + } + + var newThreadPublisher: AnyPublisher { + newThreadPublisherSubject.eraseToAnyPublisher() + } + + var acceptPublisher: AnyPublisher<(String, SentInvite), Never> { + acceptPublisherSubject.eraseToAnyPublisher() + } + + var rejectPublisher: AnyPublisher { + rejectPublisherSubject.eraseToAnyPublisher() + } + + init( + kms: KeyManagementServiceProtocol, + messageStore: KeyedDatabase, + receivedInviteStore: KeyedDatabase, + sentInviteStore: SyncStore, + threadStore: SyncStore, + inviteKeyStore: SyncStore, + receivedInviteStatusStore: SyncStore, + historyService: HistoryService, + sentInviteStoreDelegate: SentInviteStoreDelegate, + threadStoreDelegate: ThreadStoreDelegate, + inviteKeyDelegate: InviteKeyDelegate, + receiviedInviteStatusDelegate: ReceiviedInviteStatusDelegate + ) { + self.kms = kms + self.messageStore = messageStore + self.receivedInviteStore = receivedInviteStore + self.sentInviteStore = sentInviteStore + self.threadStore = threadStore + self.inviteKeyStore = inviteKeyStore + self.receivedInviteStatusStore = receivedInviteStatusStore + self.historyService = historyService + self.sentInviteStoreDelegate = sentInviteStoreDelegate + self.threadStoreDelegate = threadStoreDelegate + self.inviteKeyDelegate = inviteKeyDelegate + self.receiviedInviteStatusDelegate = receiviedInviteStatusDelegate + + setupSyncSubscriptions() + } + + // MARK: - Configuration + + func initializeStores(for account: Account) async throws { + try await sentInviteStore.initialize(for: account) + try await threadStore.initialize(for: account) + try await inviteKeyStore.initialize(for: account) + try await receivedInviteStatusStore.initialize(for: account) + } + + func initializeDelegates() async throws { + try await sentInviteStoreDelegate.onInitialization(sentInviteStore.getAll()) + try await threadStoreDelegate.onInitialization(storage: self) + try await inviteKeyDelegate.onInitialization(inviteKeyStore.getAll()) + try await receiviedInviteStatusDelegate.onInitialization() + } + + func initializeHistory(account: Account) async throws { + try await historyService.register() + + for thread in getAllThreads() { + let messages = try await historyService.fetchMessageHistory(thread: thread) + set(messages: messages, account: account) + } + } + + func setupSubscriptions(account: Account) throws { + messageStore.onUpdate = { [unowned self] in + messagesPublisherSubject.send(getMessages(account: account)) + } + receivedInviteStore.onUpdate = { [unowned self] in + receivedInvitesPublisherSubject.send(getReceivedInvites(account: account)) + } + + try sentInviteStore.setupSubscriptions(account: account) + try threadStore.setupSubscriptions(account: account) + try inviteKeyStore.setupSubscriptions(account: account) + } + + // MARK: - Invites + + func getReceivedInvite(id: Int64) -> ReceivedInvite? { + return receivedInviteStore.getAll() + .first(where: { $0.id == id }) + } + + func getSentInvite(id: Int64) -> SentInvite? { + return sentInviteStore.getAll() + .first(where: { $0.id == id }) + } + + func set(receivedInvite: ReceivedInvite, account: Account) { + receivedInviteStore.set(element: receivedInvite, for: account.absoluteString) + newReceivedInvitePublisherSubject.send(receivedInvite) + } + + func set(sentInvite: SentInvite, account: Account) async throws { + try await sentInviteStore.set(object: sentInvite, for: account) + newSentInvitePublisherSubject.send(sentInvite) + } + + func getReceivedInvites(account: Account) -> [ReceivedInvite] { + return receivedInviteStore.getAll(for: account.absoluteString) + } + + func syncRejectedReceivedInviteStatus(id: Int64, account: Account) async throws { + let status = ReceivedInviteStatus(id: id, status: .rejected) + try await receivedInviteStatusStore.set(object: status, for: account) + } + + func getReceivedInvites(thread: Thread) -> [ReceivedInvite] { + return getReceivedInvites(account: thread.selfAccount) + .filter { $0.inviterAccount == thread.peerAccount } + } + + func getSentInvites(account: Account) -> [SentInvite] { + do { + return try sentInviteStore.getAll(for: account) + } catch { + // TODO: remove fatalError + fatalError(error.localizedDescription) + } + } + + func accept(receivedInvite: ReceivedInvite, account: Account) { + receivedInviteStore.delete(id: receivedInvite.databaseId, for: account.absoluteString) + + let accepted = ReceivedInvite(invite: receivedInvite, status: .approved) + receivedInviteStore.set(element: accepted, for: account.absoluteString) + } + + func reject(receivedInvite: ReceivedInvite, account: Account) { + receivedInviteStore.delete(id: receivedInvite.databaseId, for: account.absoluteString) + + let rejected = ReceivedInvite(invite: receivedInvite, status: .rejected) + receivedInviteStore.set(element: rejected, for: account.absoluteString) + } + + func accept(sentInviteId: Int64, topic: String) async throws { + guard let invite = getSentInvite(id: sentInviteId) + else { return } + + let approved = SentInvite(invite: invite, status: .approved) + try await sentInviteStore.set(object: approved, for: invite.inviterAccount) + + acceptPublisherSubject.send((topic, approved)) + } + + func reject(sentInviteId: Int64) async throws { + guard let invite = getSentInvite(id: sentInviteId) + else { return } + + let rejected = SentInvite(invite: invite, status: .rejected) + try await sentInviteStore.set(object: rejected, for: invite.inviterAccount) + + rejectPublisherSubject.send(rejected) + } + + // MARK: InviteKeys + + func setInviteKey(_ inviteKey: AgreementPublicKey, account: Account) async throws { + if let privateKey = try kms.getPrivateKey(for: inviteKey) { + let pubKeyHex = inviteKey.hexRepresentation + let privKeyHex = privateKey.rawRepresentation.toHexString() + let key = InviteKey(publicKey: pubKeyHex, privateKey: privKeyHex, account: account) + try await inviteKeyStore.set(object: key, for: account) + } + } + + func removeInviteKey(_ inviteKey: AgreementPublicKey, account: Account) async throws { + try await inviteKeyStore.delete(id: inviteKey.hexRepresentation, for: account) + } + + // MARK: - Threads + + func getAllThreads() -> [Thread] { + return threadStore.getAll() + } + + func getThreads(account: Account) -> [Thread] { + do { + return try threadStore.getAll(for: account) + } catch { + // TODO: remove fatalError + fatalError(error.localizedDescription) + } + } + + func getThread(topic: String) -> Thread? { + return getAllThreads().first(where: { $0.topic == topic }) + } + + func set(thread: Thread, account: Account) async throws { + try await threadStore.set(object: thread, for: account) + newThreadPublisherSubject.send(thread) + } + + // MARK: - Messages + + func set(message: Message, account: Account) { + messageStore.set(element: message, for: account.absoluteString) + newMessagePublisherSubject.send(message) + } + + func set(messages: [Message], account: Account) { + messageStore.set(elements: messages, for: account.absoluteString) + } + + func getMessages(topic: String) -> [Message] { + return messageStore.getAll().filter { $0.topic == topic } + } + + func getMessages(account: Account) -> [Message] { + return messageStore.getAll(for: account.absoluteString) + } +} + +private extension ChatStorage { + + func setupSyncSubscriptions() { + sentInviteStore.syncUpdatePublisher.sink { [unowned self] topic, account, update in + switch update { + case .set(let object): + self.sentInviteStoreDelegate.onUpdate(object) + case .delete(let id): + self.sentInviteStoreDelegate.onDelete(id) + } + }.store(in: &publishers) + + threadStore.syncUpdatePublisher.sink { [unowned self] topic, account, update in + switch update { + case .set(let object): + self.threadStoreDelegate.onUpdate(object, storage: self) + case .delete(let id): + self.threadStoreDelegate.onDelete(id) + } + }.store(in: &publishers) + + inviteKeyStore.syncUpdatePublisher.sink { [unowned self] topic, account, update in + switch update { + case .set(let object): + self.inviteKeyDelegate.onUpdate(object, account: account) + case .delete(let id): + self.inviteKeyDelegate.onDelete(id) + } + }.store(in: &publishers) + + receivedInviteStatusStore.syncUpdatePublisher.sink { [unowned self] topic, account, update in + switch update { + case .set(let object): + self.receiviedInviteStatusDelegate.onUpdate(object, storage: self, account: account) + case .delete(let id): + self.receiviedInviteStatusDelegate.onDelete(id) + } + }.store(in: &publishers) + } +} diff --git a/Sources/Chat/Storage/InviteKeyDelegate.swift b/Sources/Chat/Storage/InviteKeyDelegate.swift new file mode 100644 index 000000000..a2337df6f --- /dev/null +++ b/Sources/Chat/Storage/InviteKeyDelegate.swift @@ -0,0 +1,56 @@ +import Foundation + +final class InviteKeyDelegate { + + private let networkingInteractor: NetworkInteracting + private let kms: KeyManagementServiceProtocol + private let identityClient: IdentityClient + + init(networkingInteractor: NetworkInteracting, kms: KeyManagementServiceProtocol, identityClient: IdentityClient) { + self.networkingInteractor = networkingInteractor + self.kms = kms + self.identityClient = identityClient + } + + func onInitialization(_ keys: [InviteKey]) async throws { + for key in keys { + try syncKms(key: key) + } + + let topics = keys.map { $0.topic } + try await networkingInteractor.batchSubscribe(topics: topics) + } + + func onUpdate(_ key: InviteKey, account: Account) { + Task(priority: .high) { + try syncKms(key: key) + try syncIdentityStorage(key: key, account: account) + try await networkingInteractor.subscribe(topic: key.topic) + } + } + + func onDelete(_ id: String) { + Task(priority: .high) { + let inviteKey = try AgreementPublicKey(hex: id) // InviteKey id is pubKey hex + let topic = inviteKey.rawRepresentation.sha256().toHexString() + kms.deletePublicKey(for: topic) + networkingInteractor.unsubscribe(topic: topic) + } + } +} + +private extension InviteKeyDelegate { + + func syncKms(key: InviteKey) throws { + let inviteKey = try AgreementPublicKey(hex: key.publicKey) + let privateKey = try AgreementPrivateKey(hex: key.privateKey) + try kms.setPublicKey(publicKey: inviteKey, for: key.topic) + try kms.setPrivateKey(privateKey) + } + + func syncIdentityStorage(key: InviteKey, account: Account) throws { + let inviteKey = try AgreementPublicKey(hex: key.publicKey) + try identityClient.setInviteKey(inviteKey, account: account) + } +} + diff --git a/Sources/Chat/Storage/ReceiviedInviteStatusDelegate.swift b/Sources/Chat/Storage/ReceiviedInviteStatusDelegate.swift new file mode 100644 index 000000000..1d5fe63b9 --- /dev/null +++ b/Sources/Chat/Storage/ReceiviedInviteStatusDelegate.swift @@ -0,0 +1,20 @@ +import Foundation + +final class ReceiviedInviteStatusDelegate { + + func onInitialization() async throws { + + } + + func onUpdate(_ status: ReceivedInviteStatus, storage: ChatStorage, account: Account) { + guard status.status == .rejected else { return } + + if let receivedInvite = storage.getReceivedInvite(id: status.id) { + storage.reject(receivedInvite: receivedInvite, account: account) + } + } + + func onDelete(_ id: String) { + + } +} diff --git a/Sources/Chat/Storage/SentInviteStoreDelegate.swift b/Sources/Chat/Storage/SentInviteStoreDelegate.swift new file mode 100644 index 000000000..c48aadf76 --- /dev/null +++ b/Sources/Chat/Storage/SentInviteStoreDelegate.swift @@ -0,0 +1,44 @@ +import Foundation + +final class SentInviteStoreDelegate { + + private let networkingInteractor: NetworkInteracting + private let kms: KeyManagementServiceProtocol + + init(networkingInteractor: NetworkInteracting, kms: KeyManagementServiceProtocol) { + self.networkingInteractor = networkingInteractor + self.kms = kms + } + + func onInitialization(_ objects: [SentInvite]) async throws { + for invite in objects { + try syncKeychain(invite: invite) + } + + let topics = objects.map { $0.responseTopic } + try await networkingInteractor.batchSubscribe(topics: topics) + } + + func onUpdate(_ object: SentInvite) { + Task(priority: .high) { + try syncKeychain(invite: object) + try await networkingInteractor.subscribe(topic: object.responseTopic) + } + } + + func onDelete(_ id: String) { + // TODO: Implement unsubscribe + } +} + +private extension SentInviteStoreDelegate { + + func syncKeychain(invite: SentInvite) throws { + let symmetricKey = try SymmetricKey(hex: invite.symKey) + let agreementPrivateKey = try AgreementPrivateKey(hex: invite.inviterPrivKeyY) + + // TODO: Should we set symKey for inviteTopic??? + try kms.setSymmetricKey(symmetricKey, for: invite.responseTopic) + try kms.setPrivateKey(agreementPrivateKey) + } +} diff --git a/Sources/Chat/Storage/ThreadStoreDelegate.swift b/Sources/Chat/Storage/ThreadStoreDelegate.swift new file mode 100644 index 000000000..91e8a4afc --- /dev/null +++ b/Sources/Chat/Storage/ThreadStoreDelegate.swift @@ -0,0 +1,38 @@ +import Foundation + +final class ThreadStoreDelegate { + + private let networkingInteractor: NetworkInteracting + private let kms: KeyManagementServiceProtocol + private let historyService: HistoryService + + init(networkingInteractor: NetworkInteracting, kms: KeyManagementServiceProtocol, historyService: HistoryService) { + self.networkingInteractor = networkingInteractor + self.kms = kms + self.historyService = historyService + } + + func onInitialization(storage: ChatStorage) async throws { + let threads = storage.getAllThreads() + try await networkingInteractor.batchSubscribe(topics: threads.map { $0.topic }) + } + + func onUpdate(_ thread: Thread, storage: ChatStorage) { + Task(priority: .high) { + for receivedInvite in storage.getReceivedInvites(thread: thread) { + storage.accept(receivedInvite: receivedInvite, account: thread.selfAccount) + } + + let symmetricKey = try SymmetricKey(hex: thread.symKey) + try kms.setSymmetricKey(symmetricKey, for: thread.topic) + try await networkingInteractor.subscribe(topic: thread.topic) + + let messages = try await historyService.fetchMessageHistory(thread: thread) + storage.set(messages: messages, account: thread.selfAccount) + } + } + + func onDelete(_ id: String) { + + } +} diff --git a/Sources/Chat/Types/Plain/InviteKey.swift b/Sources/Chat/Types/Plain/InviteKey.swift new file mode 100644 index 000000000..bf5cb4796 --- /dev/null +++ b/Sources/Chat/Types/Plain/InviteKey.swift @@ -0,0 +1,15 @@ +import Foundation + +struct InviteKey: DatabaseObject { + let publicKey: String + let privateKey: String + let account: Account + + var topic: String { + return Data(hex: publicKey).sha256().toHexString() + } + + var databaseId: String { + return account.absoluteString + } +} diff --git a/Sources/Chat/Types/Plain/Message.swift b/Sources/Chat/Types/Plain/Message.swift index fe6d07c2c..f6bd8e70c 100644 --- a/Sources/Chat/Types/Plain/Message.swift +++ b/Sources/Chat/Types/Plain/Message.swift @@ -1,12 +1,16 @@ import Foundation -public struct Message: Codable, Equatable { +public struct Message: DatabaseObject { public let topic: String public let message: String public let authorAccount: Account public let timestamp: UInt64 public let media: Media? + public var databaseId: String { + return String(timestamp) + } + init( topic: String, message: String, diff --git a/Sources/Chat/Types/Plain/ReceivedInvite.swift b/Sources/Chat/Types/Plain/ReceivedInvite.swift index dde40c77b..64d20f357 100644 --- a/Sources/Chat/Types/Plain/ReceivedInvite.swift +++ b/Sources/Chat/Types/Plain/ReceivedInvite.swift @@ -1,6 +1,6 @@ import Foundation -public struct ReceivedInvite: Codable, Equatable { +public struct ReceivedInvite: DatabaseObject { public let id: Int64 public let message: String public let inviterAccount: Account @@ -10,6 +10,10 @@ public struct ReceivedInvite: Codable, Equatable { public let timestamp: UInt64 public var status: Status + public var databaseId: String { + return String(id) + } + public init( id: Int64, message: String, diff --git a/Sources/Chat/Types/Plain/ReceivedInviteStatus.swift b/Sources/Chat/Types/Plain/ReceivedInviteStatus.swift new file mode 100644 index 000000000..3263669b7 --- /dev/null +++ b/Sources/Chat/Types/Plain/ReceivedInviteStatus.swift @@ -0,0 +1,10 @@ +import Foundation + +struct ReceivedInviteStatus: DatabaseObject { + let id: Int64 + let status: ReceivedInvite.Status + + var databaseId: String { + return String(id) + } +} diff --git a/Sources/Chat/Types/Plain/SentInvite.swift b/Sources/Chat/Types/Plain/SentInvite.swift index a1d8f1dcc..011cf96dc 100644 --- a/Sources/Chat/Types/Plain/SentInvite.swift +++ b/Sources/Chat/Types/Plain/SentInvite.swift @@ -5,21 +5,33 @@ public struct SentInvite: Codable, Equatable { public let message: String public let inviterAccount: Account public let inviteeAccount: Account + public let inviterPubKeyY: String + public let inviterPrivKeyY: String + public let responseTopic: String + public let symKey: String public let timestamp: UInt64 public var status: Status - public init( + init( id: Int64, message: String, inviterAccount: Account, inviteeAccount: Account, + inviterPubKeyY: String, + inviterPrivKeyY: String, + responseTopic: String, + symKey: String, timestamp: UInt64, - status: SentInvite.Status = .pending // TODO: Implement statuses + status: SentInvite.Status = .pending ) { self.id = id self.message = message self.inviterAccount = inviterAccount self.inviteeAccount = inviteeAccount + self.inviterPubKeyY = inviterPubKeyY + self.inviterPrivKeyY = inviterPrivKeyY + self.responseTopic = responseTopic + self.symKey = symKey self.timestamp = timestamp self.status = status } @@ -30,12 +42,23 @@ public struct SentInvite: Codable, Equatable { message: invite.message, inviterAccount: invite.inviterAccount, inviteeAccount: invite.inviteeAccount, + inviterPubKeyY: invite.inviterPubKeyY, + inviterPrivKeyY: invite.inviterPrivKeyY, + responseTopic: invite.responseTopic, + symKey: invite.symKey, timestamp: invite.timestamp, status: status ) } } +extension SentInvite: DatabaseObject { + + public var databaseId: String { + return responseTopic + } +} + extension SentInvite { public enum Status: String, Codable, Equatable { diff --git a/Sources/Chat/Types/Plain/Thread.swift b/Sources/Chat/Types/Plain/Thread.swift index ed5ef5ca0..1c1015377 100644 --- a/Sources/Chat/Types/Plain/Thread.swift +++ b/Sources/Chat/Types/Plain/Thread.swift @@ -4,4 +4,12 @@ public struct Thread: Codable, Equatable { public let topic: String public let selfAccount: Account public let peerAccount: Account + public let symKey: String +} + +extension Thread: DatabaseObject { + + public var databaseId: String { + return topic + } } diff --git a/Sources/WalletConnectHistory/HistoryAPI.swift b/Sources/WalletConnectHistory/HistoryAPI.swift new file mode 100644 index 000000000..58f6b0cbd --- /dev/null +++ b/Sources/WalletConnectHistory/HistoryAPI.swift @@ -0,0 +1,56 @@ +import Foundation + +enum HistoryAPI: HTTPService { + case register(payload: RegisterPayload, jwt: String) + case messages(payload: GetMessagesPayload) + + var path: String { + switch self { + case .register: + return "/register" + case .messages: + return "/messages" + } + } + + var method: HTTPMethod { + switch self { + case .register: + return .post + case .messages: + return .get + } + } + + var body: Data? { + switch self { + case .register(let payload, _): + return try? JSONEncoder().encode(payload) + case .messages: + return nil + } + } + + var additionalHeaderFields: [String : String]? { + switch self { + case .register(_, let jwt): + return ["Authorization": "Bearer \(jwt)"] + case .messages: + return nil + } + } + + var queryParameters: [String: String]? { + switch self { + case .messages(let payload): + return [ + "topic": payload.topic, + "originId": payload.originId.map { String($0) }, + "messageCount": payload.messageCount.map { String($0) }, + "direction": payload.direction.rawValue + ].compactMapValues { $0 } + case .register: + return nil + } + } +} diff --git a/Sources/WalletConnectHistory/HistoryClient.swift b/Sources/WalletConnectHistory/HistoryClient.swift new file mode 100644 index 000000000..be8673b4e --- /dev/null +++ b/Sources/WalletConnectHistory/HistoryClient.swift @@ -0,0 +1,40 @@ +import Foundation + +public final class HistoryClient { + + private let historyUrl: String + private let relayUrl: String + private let serializer: Serializer + private let historyNetworkService: HistoryNetworkService + + init(historyUrl: String, relayUrl: String, serializer: Serializer, historyNetworkService: HistoryNetworkService) { + self.historyUrl = historyUrl + self.relayUrl = relayUrl + self.serializer = serializer + self.historyNetworkService = historyNetworkService + } + + public func register(tags: [String]) async throws { + let payload = RegisterPayload(tags: tags, relayUrl: relayUrl) + try await historyNetworkService.registerTags(payload: payload, historyUrl: historyUrl) + } + + public func getMessages(topic: String, count: Int, direction: GetMessagesPayload.Direction) async throws -> [T] { + let payload = GetMessagesPayload(topic: topic, originId: nil, messageCount: count, direction: direction) + let response = try await historyNetworkService.getMessages(payload: payload, historyUrl: historyUrl) + + let objects = response.messages.compactMap { payload in + do { + let (request, _, _): (RPCRequest, _, _) = try serializer.deserialize( + topic: topic, + encodedEnvelope: payload + ) + return try request.params?.get(T.self) + } catch { + fatalError(error.localizedDescription) + } + } + + return objects + } +} diff --git a/Sources/WalletConnectHistory/HistoryClientFactory.swift b/Sources/WalletConnectHistory/HistoryClientFactory.swift new file mode 100644 index 000000000..d8c69b49a --- /dev/null +++ b/Sources/WalletConnectHistory/HistoryClientFactory.swift @@ -0,0 +1,25 @@ +import Foundation + +public class HistoryClientFactory { + + public static func create(keychain: KeychainStorageProtocol) -> HistoryClient { + return HistoryClientFactory.create( + historyUrl: "https://history.walletconnect.com", + relayUrl: "wss://relay.walletconnect.com", + keychain: keychain + ) + } + + static func create(historyUrl: String, relayUrl: String, keychain: KeychainStorageProtocol) -> HistoryClient { + let clientIdStorage = ClientIdStorage(keychain: keychain) + let kms = KeyManagementService(keychain: keychain) + let serializer = Serializer(kms: kms) + let historyNetworkService = HistoryNetworkService(clientIdStorage: clientIdStorage) + return HistoryClient( + historyUrl: historyUrl, + relayUrl: relayUrl, + serializer: serializer, + historyNetworkService: historyNetworkService + ) + } +} diff --git a/Sources/WalletConnectHistory/HistoryImports.swift b/Sources/WalletConnectHistory/HistoryImports.swift new file mode 100644 index 000000000..e6d4b859c --- /dev/null +++ b/Sources/WalletConnectHistory/HistoryImports.swift @@ -0,0 +1,4 @@ +#if !CocoaPods +@_exported import HTTPClient +@_exported import WalletConnectRelay +#endif diff --git a/Sources/WalletConnectHistory/HistoryNetworkService.swift b/Sources/WalletConnectHistory/HistoryNetworkService.swift new file mode 100644 index 000000000..f4d01ddb8 --- /dev/null +++ b/Sources/WalletConnectHistory/HistoryNetworkService.swift @@ -0,0 +1,41 @@ +import Foundation + +final class HistoryNetworkService { + + private let clientIdStorage: ClientIdStorage + + init(clientIdStorage: ClientIdStorage) { + self.clientIdStorage = clientIdStorage + } + + func registerTags(payload: RegisterPayload, historyUrl: String) async throws { + let service = HTTPNetworkClient(host: try host(from: historyUrl)) + let api = HistoryAPI.register(payload: payload, jwt: try getJwt(historyUrl: historyUrl)) + try await service.request(service: api) + } + + func getMessages(payload: GetMessagesPayload, historyUrl: String) async throws -> GetMessagesResponse { + let service = HTTPNetworkClient(host: try host(from: historyUrl)) + let api = HistoryAPI.messages(payload: payload) + return try await service.request(GetMessagesResponse.self, at: api) + } +} + +private extension HistoryNetworkService { + + enum Errors: Error { + case couldNotResolveHost + } + + func getJwt(historyUrl: String) throws -> String { + let authenticator = ClientIdAuthenticator(clientIdStorage: clientIdStorage, url: historyUrl) + return try authenticator.createAuthToken() + } + + func host(from url: String) throws -> String { + guard let host = URL(string: url)?.host else { + throw Errors.couldNotResolveHost + } + return host + } +} diff --git a/Sources/WalletConnectHistory/Types/GetMessagesPayload.swift b/Sources/WalletConnectHistory/Types/GetMessagesPayload.swift new file mode 100644 index 000000000..7dcc9a08d --- /dev/null +++ b/Sources/WalletConnectHistory/Types/GetMessagesPayload.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct GetMessagesPayload: Codable { + public enum Direction: String, Codable { + case forward + case backward + } + public let topic: String + public let originId: Int64? + public let messageCount: Int? + public let direction: Direction + + public init(topic: String, originId: Int64?, messageCount: Int?, direction: GetMessagesPayload.Direction) { + self.topic = topic + self.originId = originId + self.messageCount = messageCount + self.direction = direction + } +} diff --git a/Sources/WalletConnectHistory/Types/GetMessagesResponse.swift b/Sources/WalletConnectHistory/Types/GetMessagesResponse.swift new file mode 100644 index 000000000..032bad07e --- /dev/null +++ b/Sources/WalletConnectHistory/Types/GetMessagesResponse.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct GetMessagesResponse: Decodable { + public struct Message: Codable { + public let message: String + } + public let topic: String + public let direction: GetMessagesPayload.Direction + public let nextId: Int64? + public let messages: [String] + + enum CodingKeys: String, CodingKey { + case topic + case direction + case nextId + case messages + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.topic = try container.decode(String.self, forKey: .topic) + self.direction = try container.decode(GetMessagesPayload.Direction.self, forKey: .direction) + self.nextId = try container.decodeIfPresent(Int64.self, forKey: .nextId) + + let messages = try container.decode([Message].self, forKey: .messages) + self.messages = messages.map { $0.message } + } +} diff --git a/Sources/WalletConnectHistory/Types/RegisterPayload.swift b/Sources/WalletConnectHistory/Types/RegisterPayload.swift new file mode 100644 index 000000000..b759c5ce5 --- /dev/null +++ b/Sources/WalletConnectHistory/Types/RegisterPayload.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct RegisterPayload: Codable { + public let tags: [String] + public let relayUrl: String + + public init(tags: [String], relayUrl: String) { + self.tags = tags + self.relayUrl = relayUrl + } +} diff --git a/Sources/WalletConnectIdentity/IdentityClient.swift b/Sources/WalletConnectIdentity/IdentityClient.swift index bbccb14b5..8ec6d2943 100644 --- a/Sources/WalletConnectIdentity/IdentityClient.swift +++ b/Sources/WalletConnectIdentity/IdentityClient.swift @@ -45,6 +45,14 @@ public final class IdentityClient { return inviteKey } + public func setInviteKey(_ inviteKey: AgreementPublicKey, account: Account) throws { + try identityStorage.saveInviteKey(inviteKey, for: account) + } + + public func deleteInviteKey(account: Account) throws { + try identityStorage.removeInviteKey(for: account) + } + public func resolveInvite(account: Account) async throws -> String { return try await identityService.resolveInvite(account: account) } diff --git a/Sources/WalletConnectIdentity/IdentityService.swift b/Sources/WalletConnectIdentity/IdentityService.swift index 7d9a7e2c7..8a1a984a8 100644 --- a/Sources/WalletConnectIdentity/IdentityService.swift +++ b/Sources/WalletConnectIdentity/IdentityService.swift @@ -99,7 +99,7 @@ private extension IdentityService { version: getVersion(), nonce: getNonce(), iat: iatProvader.iat, - nbf: nil, exp: nil, statement: nil, requestId: nil, + nbf: nil, exp: nil, statement: "statement", requestId: nil, resources: [DIDKey] ) diff --git a/Sources/WalletConnectIdentity/IdentityStorage.swift b/Sources/WalletConnectIdentity/IdentityStorage.swift index 0e591f097..0988cd045 100644 --- a/Sources/WalletConnectIdentity/IdentityStorage.swift +++ b/Sources/WalletConnectIdentity/IdentityStorage.swift @@ -18,7 +18,7 @@ public final class IdentityStorage { } @discardableResult - func saveInviteKey( + public func saveInviteKey( _ key: AgreementPublicKey, for account: Account ) throws -> AgreementPublicKey { @@ -30,7 +30,7 @@ public final class IdentityStorage { try keychain.delete(key: identityKeyIdentifier(for: account)) } - func removeInviteKey(for account: Account) throws { + public func removeInviteKey(for account: Account) throws { try keychain.delete(key: inviteKeyIdentifier(for: account)) } diff --git a/Sources/WalletConnectKMS/Crypto/CryptoKitWrapper/AgreementCryptoKit.swift b/Sources/WalletConnectKMS/Crypto/CryptoKitWrapper/AgreementCryptoKit.swift index f5e0f906e..b61fdc66c 100644 --- a/Sources/WalletConnectKMS/Crypto/CryptoKitWrapper/AgreementCryptoKit.swift +++ b/Sources/WalletConnectKMS/Crypto/CryptoKitWrapper/AgreementCryptoKit.swift @@ -47,7 +47,7 @@ public struct AgreementPublicKey: GenericPasswordConvertible, Equatable { } public var hexRepresentation: String { - key.rawRepresentation.toHexString() + rawRepresentation.toHexString() } public var did: String { @@ -80,6 +80,11 @@ public struct AgreementPrivateKey: GenericPasswordConvertible, Equatable { self.key = Curve25519.KeyAgreement.PrivateKey() } + public init(hex: String) throws { + let data = Data(hex: hex) + try self.init(rawRepresentation: data) + } + public init(rawRepresentation: D) throws where D: ContiguousBytes { self.key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: rawRepresentation) } diff --git a/Sources/WalletConnectKMS/Keychain/String+GenericPasswordConvertible.swift b/Sources/WalletConnectKMS/Keychain/String+GenericPasswordConvertible.swift new file mode 100644 index 000000000..4c8717a4e --- /dev/null +++ b/Sources/WalletConnectKMS/Keychain/String+GenericPasswordConvertible.swift @@ -0,0 +1,20 @@ +import Foundation + +extension String: GenericPasswordConvertible { + + public init(rawRepresentation data: D) throws where D: ContiguousBytes { + let buffer = data.withUnsafeBytes { Data($0) } + guard let string = String(data: buffer, encoding: .utf8) else { + throw Errors.notUTF8 + } + self = string + } + + public var rawRepresentation: Data { + return data(using: .utf8) ?? Data() + } +} + +fileprivate enum Errors: Error { + case notUTF8 +} diff --git a/Sources/WalletConnectRelay/ClientAuth/SocketAuthenticator.swift b/Sources/WalletConnectRelay/ClientAuth/ClientIdAuthenticator.swift similarity index 56% rename from Sources/WalletConnectRelay/ClientAuth/SocketAuthenticator.swift rename to Sources/WalletConnectRelay/ClientAuth/ClientIdAuthenticator.swift index bb7a8b540..2055e1813 100644 --- a/Sources/WalletConnectRelay/ClientAuth/SocketAuthenticator.swift +++ b/Sources/WalletConnectRelay/ClientAuth/ClientIdAuthenticator.swift @@ -1,28 +1,24 @@ import Foundation -protocol SocketAuthenticating { +public protocol ClientIdAuthenticating { func createAuthToken() throws -> String } -struct SocketAuthenticator: SocketAuthenticating { +public struct ClientIdAuthenticator: ClientIdAuthenticating { private let clientIdStorage: ClientIdStoring - private let relayHost: String + private let url: String - init(clientIdStorage: ClientIdStoring, relayHost: String) { + public init(clientIdStorage: ClientIdStoring, url: String) { self.clientIdStorage = clientIdStorage - self.relayHost = relayHost + self.url = url } - func createAuthToken() throws -> String { + public func createAuthToken() throws -> String { let keyPair = try clientIdStorage.getOrCreateKeyPair() - let payload = RelayAuthPayload(subject: getSubject(), audience: getAudience()) + let payload = RelayAuthPayload(subject: getSubject(), audience: url) return try payload.signAndCreateWrapper(keyPair: keyPair).jwtString } - private func getAudience() -> String { - return "wss://\(relayHost)" - } - private func getSubject() -> String { return Data.randomBytes(count: 32).toHexString() } diff --git a/Sources/WalletConnectRelay/PackageConfig.json b/Sources/WalletConnectRelay/PackageConfig.json index 18a6f779c..0a1b0e189 100644 --- a/Sources/WalletConnectRelay/PackageConfig.json +++ b/Sources/WalletConnectRelay/PackageConfig.json @@ -1 +1 @@ -{"version": "1.6.7"} +{"version": "1.6.8"} diff --git a/Sources/WalletConnectRelay/RelayClient.swift b/Sources/WalletConnectRelay/RelayClient.swift index 880e4b79c..a84c4c264 100644 --- a/Sources/WalletConnectRelay/RelayClient.swift +++ b/Sources/WalletConnectRelay/RelayClient.swift @@ -80,9 +80,9 @@ public final class RelayClient { logger: ConsoleLogging = ConsoleLogger(loggingLevel: .debug) ) { let clientIdStorage = ClientIdStorage(keychain: keychainStorage) - let socketAuthenticator = SocketAuthenticator( + let socketAuthenticator = ClientIdAuthenticator( clientIdStorage: clientIdStorage, - relayHost: relayHost + url: "wss://\(relayHost)" ) let relayUrlFactory = RelayUrlFactory( relayHost: relayHost, diff --git a/Sources/WalletConnectRelay/RelayURLFactory.swift b/Sources/WalletConnectRelay/RelayURLFactory.swift index d3b9d155c..ec35ce87c 100644 --- a/Sources/WalletConnectRelay/RelayURLFactory.swift +++ b/Sources/WalletConnectRelay/RelayURLFactory.swift @@ -3,12 +3,12 @@ import Foundation struct RelayUrlFactory { private let relayHost: String private let projectId: String - private let socketAuthenticator: SocketAuthenticating + private let socketAuthenticator: ClientIdAuthenticating init( relayHost: String, projectId: String, - socketAuthenticator: SocketAuthenticating + socketAuthenticator: ClientIdAuthenticating ) { self.relayHost = relayHost self.projectId = projectId diff --git a/Sources/WalletConnectSign/Session.swift b/Sources/WalletConnectSign/Session.swift index c880f6cd0..ef3a2205f 100644 --- a/Sources/WalletConnectSign/Session.swift +++ b/Sources/WalletConnectSign/Session.swift @@ -41,4 +41,10 @@ extension Session { SessionType.EventParams.Event(name: name, data: data) } } + + public var accounts: [Account] { + return namespaces.values.reduce(into: []) { result, namespace in + result = result + Array(namespace.accounts) + } + } } diff --git a/Sources/WalletConnectSign/Sign/SignClientProtocol.swift b/Sources/WalletConnectSign/Sign/SignClientProtocol.swift index a1dcfc3f3..397a59bf4 100644 --- a/Sources/WalletConnectSign/Sign/SignClientProtocol.swift +++ b/Sources/WalletConnectSign/Sign/SignClientProtocol.swift @@ -9,7 +9,10 @@ public protocol SignClientProtocol { var sessionSettlePublisher: AnyPublisher { get } var sessionDeletePublisher: AnyPublisher<(String, Reason), Never> { get } var sessionResponsePublisher: AnyPublisher { get } + var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { get } + func connect(requiredNamespaces: [String: ProposalNamespace], optionalNamespaces: [String: ProposalNamespace]?, sessionProperties: [String: String]?, topic: String) async throws + func request(params: Request) async throws func approve(proposalId: String, namespaces: [String: SessionNamespace]) async throws func reject(proposalId: String, reason: RejectionReason) async throws func update(topic: String, namespaces: [String: SessionNamespace]) async throws diff --git a/Sources/WalletConnectSigner/BIP44Provider.swift b/Sources/WalletConnectSigner/BIP44Provider.swift new file mode 100644 index 000000000..806fe0c5e --- /dev/null +++ b/Sources/WalletConnectSigner/BIP44Provider.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum DerivationPath { + case hardened(UInt32) + case notHardened(UInt32) +} + +public protocol BIP44Provider { + func derive(entropy: Data, path: [DerivationPath]) -> Data +} diff --git a/Sources/WalletConnectSync/Services/SyncDerivationService.swift b/Sources/WalletConnectSync/Services/SyncDerivationService.swift new file mode 100644 index 000000000..d859f00fc --- /dev/null +++ b/Sources/WalletConnectSync/Services/SyncDerivationService.swift @@ -0,0 +1,63 @@ +import Foundation + +final class SyncDerivationService { + + private let syncStorage: SyncSignatureStore + private let bip44: BIP44Provider + private let kms: KeyManagementServiceProtocol + + init( + syncStorage: SyncSignatureStore, + bip44: BIP44Provider, + kms: KeyManagementServiceProtocol + ) { + self.syncStorage = syncStorage + self.bip44 = bip44 + self.kms = kms + } + + func deriveTopic(account: Account, store: String) throws -> String { + let signature = try syncStorage.getSignature(for: account) + + guard let signatureData = signature.data(using: .utf8) else { + throw Errors.signatureIsNotUTF8 + } + + let slice = store.components(withMaxLength: 4) + .compactMap { $0.data(using: .utf8) } + .compactMap { UInt32($0.toHexString(), radix: 16) } + + let path: [DerivationPath] = [ + .hardened(77), + .hardened(0), + .notHardened(0) + ] + slice.map { .notHardened($0) } + + let entropy = signatureData.sha256() + let storeKey = bip44.derive(entropy: entropy, path: path) + let topic = storeKey.sha256().toHexString() + + let symmetricKey = try SymmetricKey(rawRepresentation: storeKey) + try kms.setSymmetricKey(symmetricKey, for: topic) + + return topic + } +} + +private extension SyncDerivationService { + + enum Errors: Error { + case signatureIsNotUTF8 + } +} + +fileprivate extension String { + + func components(withMaxLength length: Int) -> [String] { + return stride(from: 0, to: count, by: length).map { + let start = index(startIndex, offsetBy: $0) + let end = index(start, offsetBy: length, limitedBy: endIndex) ?? endIndex + return String(self[start..() + + var updatePublisher: AnyPublisher<(String, StoreUpdate), Never> { + return updateSubject.eraseToAnyPublisher() + } + + private var publishers: Set = [] + + private let networkInteractor: NetworkInteracting + private let derivationService: SyncDerivationService + private let signatureStore: SyncSignatureStore + private let historyStore: SyncHistoryStore + private let logger: ConsoleLogging + + /// `account` to `Record` keyValue store + private let indexStore: SyncIndexStore + + init(networkInteractor: NetworkInteracting, derivationService: SyncDerivationService, signatureStore: SyncSignatureStore, indexStore: SyncIndexStore, historyStore: SyncHistoryStore, logger: ConsoleLogging) { + self.networkInteractor = networkInteractor + self.derivationService = derivationService + self.signatureStore = signatureStore + self.indexStore = indexStore + self.historyStore = historyStore + self.logger = logger + + setupSubscriptions() + } + + func set(account: Account, store: String, object: Object) async throws { + let protocolMethod = SyncSetMethod() + let params = StoreSet(key: object.databaseId, value: try object.json()) + let rpcid = RPCID() + let request = RPCRequest(method: protocolMethod.method, params: params, rpcid: rpcid) + let record = try indexStore.getRecord(account: account, name: store) + + try await networkInteractor.request(request, topic: record.topic, protocolMethod: protocolMethod) + + historyStore.set(rpcid: rpcid.integer, topic: record.topic) + + logger.debug("Did set value for \(store). Sent on \(record.topic). Object: \n\(object)\n") + } + + func delete(account: Account, store: String, key: String) async throws { + let protocolMethod = SyncDeleteMethod() + let rpcid = RPCID() + let request = RPCRequest(method: protocolMethod.method, params: ["key": key], rpcid: rpcid) + let record = try indexStore.getRecord(account: account, name: store) + + try await networkInteractor.request(request, topic: record.topic, protocolMethod: protocolMethod) + + historyStore.set(rpcid: rpcid.integer, topic: record.topic) + + logger.debug("Did delete value for \(store). Sent on: \(record.topic). Key: \n\(key)\n") + } + + func create(account: Account, store: String) async throws { + let topic = try getTopic(for: account, store: store) + try await networkInteractor.subscribe(topic: topic) + } +} + +private extension SyncService { + + enum Errors: Error { + case recordNotFoundForAccount + } + + func setupSubscriptions() { + networkInteractor.requestSubscription(on: SyncSetMethod()) + .sink { [unowned self] (payload: RequestSubscriptionPayload) in + if historyStore.update(topic: payload.topic, rpcid: payload.id) { + self.updateSubject.send((payload.topic, .set(payload.request))) + } + } + .store(in: &publishers) + + networkInteractor.requestSubscription(on: SyncDeleteMethod()) + .sink { [unowned self] (payload: RequestSubscriptionPayload) in + if historyStore.update(topic: payload.topic, rpcid: payload.id) { + self.updateSubject.send((payload.topic, .delete(payload.request))) + } + } + .store(in: &publishers) + } + + func getTopic(for account: Account, store: String) throws -> String { + if let record = try? indexStore.getRecord(account: account, name: store) { + return record.topic + } + + let topic = try derivationService.deriveTopic(account: account, store: store) + indexStore.set(topic: topic, name: store, account: account) + return topic + } +} diff --git a/Sources/WalletConnectSync/Stores/SyncHistoryStore.swift b/Sources/WalletConnectSync/Stores/SyncHistoryStore.swift new file mode 100644 index 000000000..a2f290074 --- /dev/null +++ b/Sources/WalletConnectSync/Stores/SyncHistoryStore.swift @@ -0,0 +1,29 @@ +import Foundation + +final class SyncHistoryStore { + + /// `topic` to `rpcid` keyValue store + private let store: CodableStore + + init(store: CodableStore) { + self.store = store + } + + func set(rpcid: Int64, topic: String) { + store.set(rpcid, forKey: topic) + } + + func update(topic: String, rpcid: RPCID) -> Bool { + guard isNew(topic: topic, rpcid: rpcid) else { return false } + store.set(rpcid.integer, forKey: topic) + return true + } +} + +private extension SyncHistoryStore { + + func isNew(topic: String, rpcid: RPCID) -> Bool { + guard let old = try? store.get(key: topic) else { return true } + return old < rpcid.integer + } +} diff --git a/Sources/WalletConnectSync/Stores/SyncIndexStore.swift b/Sources/WalletConnectSync/Stores/SyncIndexStore.swift new file mode 100644 index 000000000..e92def273 --- /dev/null +++ b/Sources/WalletConnectSync/Stores/SyncIndexStore.swift @@ -0,0 +1,44 @@ +import Foundation + +final class SyncIndexStore { + + /// `account-store` to SyncRecord map keyValue store + private let store: CodableStore + + init(store: CodableStore) { + self.store = store + } + + func getRecord(account: Account, name: String) throws -> SyncRecord { + let identifier = identifier(account: account, name: name) + guard let record = try store.get(key: identifier) else { + throw Errors.recordNotFoundForAccount + } + return record + } + + func getRecord(topic: String) throws -> SyncRecord { + guard let record = store.getAll().first(where: { $0.topic == topic }) else { + throw Errors.accountNotFoundForTopic + } + return record + } + + func set(topic: String, name: String, account: Account) { + let identifier = identifier(account: account, name: name) + let record = SyncRecord(topic: topic, store: name, account: account) + store.set(record, forKey: identifier) + } +} + +private extension SyncIndexStore { + + enum Errors: Error { + case recordNotFoundForAccount + case accountNotFoundForTopic + } + + func identifier(account: Account, name: String) -> String { + return "\(account.absoluteString)-\(name)" + } +} diff --git a/Sources/WalletConnectSync/Stores/SyncSignatureStore.swift b/Sources/WalletConnectSync/Stores/SyncSignatureStore.swift new file mode 100644 index 000000000..e8c5b69c0 --- /dev/null +++ b/Sources/WalletConnectSync/Stores/SyncSignatureStore.swift @@ -0,0 +1,39 @@ +import Foundation +import Combine + +final class SyncSignatureStore { + + private let keychain: KeychainStorageProtocol + + init(keychain: KeychainStorageProtocol) { + self.keychain = keychain + } + + func saveSignature(_ key: String, for account: Account) throws { + try keychain.add(key, forKey: signatureIdentifier(for: account)) + } + + func getSignature(for account: Account) throws -> String { + let identifier = signatureIdentifier(for: account) + + guard let key: String = try? keychain.read(key: identifier) + else { throw Errors.signatureNotFound } + + return key + } + + func isSignatureExists(account: Account) -> Bool { + return (try? getSignature(for: account)) != nil + } +} + +private extension SyncSignatureStore { + + enum Errors: Error { + case signatureNotFound + } + + func signatureIdentifier(for account: Account) -> String { + return "com.walletconnect.sync.signature.\(account.absoluteString)" + } +} diff --git a/Sources/WalletConnectSync/Stores/SyncStore.swift b/Sources/WalletConnectSync/Stores/SyncStore.swift new file mode 100644 index 000000000..57b5320b4 --- /dev/null +++ b/Sources/WalletConnectSync/Stores/SyncStore.swift @@ -0,0 +1,112 @@ +import Foundation +import Combine + +public enum SyncUpdate { + case set(object: Object) + case delete(id: String) +} + +public final class SyncStore { + + private var publishers = Set() + + private let name: String + private let syncClient: SyncClient + + /// `account` to `Record` keyValue store + private let indexStore: SyncIndexStore + + /// `storeTopic` to [`id`: `Object`] map keyValue store + private let objectStore: KeyedDatabase + + private let dataUpdateSubject = PassthroughSubject<[Object], Never>() + private let syncUpdateSubject = PassthroughSubject<(String, Account, SyncUpdate), Never>() + + public var dataUpdatePublisher: AnyPublisher<[Object], Never> { + return dataUpdateSubject.eraseToAnyPublisher() + } + + public var syncUpdatePublisher: AnyPublisher<(String, Account, SyncUpdate), Never> { + return syncUpdateSubject.eraseToAnyPublisher() + } + + init(name: String, syncClient: SyncClient, indexStore: SyncIndexStore, objectStore: KeyedDatabase) { + self.name = name + self.syncClient = syncClient + self.indexStore = indexStore + self.objectStore = objectStore + + setupSubscriptions() + } + + public func initialize(for account: Account) async throws { + try await syncClient.create(account: account, store: name) + } + + public func setupSubscriptions(account: Account) throws { + let record = try indexStore.getRecord(account: account, name: name) + + objectStore.onUpdate = { [unowned self] in + dataUpdateSubject.send(objectStore.getAll(for: record.topic)) + } + } + + public func getAll(for account: Account) throws -> [Object] { + let record = try indexStore.getRecord(account: account, name: name) + return objectStore.getAll(for: record.topic) + } + + public func getAll() -> [Object] { + return objectStore.getAll() + } + + public func set(object: Object, for account: Account) async throws { + let record = try indexStore.getRecord(account: account, name: name) + + if objectStore.set(element: object, for: record.topic) { + try await syncClient.set(account: account, store: record.store, object: object) + } + } + + public func delete(id: String, for account: Account) async throws { + let record = try indexStore.getRecord(account: account, name: name) + + if objectStore.delete(id: id, for: record.topic) { + try await syncClient.delete(account: account, store: record.store, key: id) + } + } +} + +private extension SyncStore { + + func setupSubscriptions() { + syncClient.updatePublisher.sink { [unowned self] (topic, update) in + + let record = try! indexStore.getRecord(topic: topic) + + guard record.store == name else { return } + + switch update { + case .set(let set): + let object = try! JSONDecoder().decode(Object.self, from: Data(set.value.utf8)) + if try! setInStore(object: object, for: record.account) { + syncUpdateSubject.send((topic, record.account, .set(object: object))) + } + case .delete(let delete): + if try! deleteInStore(id: delete.key, for: record.account) { + syncUpdateSubject.send((topic, record.account, .delete(id: delete.key))) + } + } + }.store(in: &publishers) + } + + func setInStore(object: Object, for account: Account) throws -> Bool { + let record = try indexStore.getRecord(account: account, name: name) + return objectStore.set(element: object, for: record.topic) + } + + func deleteInStore(id: String, for account: Account) throws -> Bool { + let record = try indexStore.getRecord(account: account, name: name) + return objectStore.delete(id: id, for: record.topic) + } +} diff --git a/Sources/WalletConnectSync/Stores/SyncStoreFactory.swift b/Sources/WalletConnectSync/Stores/SyncStoreFactory.swift new file mode 100644 index 000000000..cfd55b22d --- /dev/null +++ b/Sources/WalletConnectSync/Stores/SyncStoreFactory.swift @@ -0,0 +1,12 @@ +import Foundation + +public final class SyncStoreFactory { + + public static func create(name: String, syncClient: SyncClient, storage: KeyValueStorage) -> SyncStore { + let indexDatabase = CodableStore(defaults: UserDefaults.standard, identifier: SyncStorageIdentifiers.index.identifier) + let indexStore = SyncIndexStore(store: indexDatabase) + let objectIdentifier = SyncStorageIdentifiers.object(store: name).identifier + let objectStore = KeyedDatabase(storage: storage, identifier: objectIdentifier) + return SyncStore(name: name, syncClient: syncClient, indexStore: indexStore, objectStore: objectStore) + } +} diff --git a/Sources/WalletConnectSync/Sync.swift b/Sources/WalletConnectSync/Sync.swift new file mode 100644 index 000000000..6144b16e5 --- /dev/null +++ b/Sources/WalletConnectSync/Sync.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Sync instatnce wrapper +public class Sync { + + /// Sync client instance + public static var instance: SyncClient = { + guard let config = config else { + fatalError("Error - you must call Sync.configure(_:) before accessing the shared instance.") + } + return SyncClientFactory.create( + networkInteractor: Networking.interactor, + bip44: config.bip44 + ) + }() + + private static var config: Config? + + private init() { } + + /// Auth instance wallet config method. For DApp usage + /// - Parameters: + /// - crypto: Crypto utils implementation + static public func configure(bip44: BIP44Provider) { + Sync.config = Sync.Config(bip44: bip44) + } +} diff --git a/Sources/WalletConnectSync/SyncClient.swift b/Sources/WalletConnectSync/SyncClient.swift new file mode 100644 index 000000000..fbe09bf6f --- /dev/null +++ b/Sources/WalletConnectSync/SyncClient.swift @@ -0,0 +1,61 @@ +import Foundation +import Combine + +public final class SyncClient { + + public var updatePublisher: AnyPublisher<(String, StoreUpdate), Never> { + return syncService.updatePublisher + } + + private let syncService: SyncService + private let syncSignatureStore: SyncSignatureStore + + init(syncService: SyncService, syncSignatureStore: SyncSignatureStore) { + self.syncService = syncService + self.syncSignatureStore = syncSignatureStore + } + + /// Get message to sign for an account + public func getMessage(account: Account) -> String { + return """ + I authorize this app to sync my account: \(account.absoluteString) + + Read more about it here: https://walletconnect.com/faq + """ + } + + /// Checks if account is already registered in sync + public func isRegistered(account: Account) -> Bool { + return syncSignatureStore.isSignatureExists(account: account) + } + + /// Register an account to sync + public func register(account: Account, signature: CacaoSignature) async throws { + // TODO: Signature verify + try syncSignatureStore.saveSignature(signature.s, for: account) + } + + /// Create a store + public func create(account: Account, store: String) async throws { + try await syncService.create(account: account, store: store) + } + + // Set value to store + public func set( + account: Account, + store: String, + object: Object + ) async throws { + try await syncService.set(account: account, store: store, object: object) + } + + // Set value from store by key + public func delete(account: Account, store: String, key: String) async throws { + try await syncService.delete(account: account, store: store, key: key) + } + + // Get stores + public func getStores(account: Account) -> StoreMap { + fatalError() + } +} diff --git a/Sources/WalletConnectSync/SyncClientFactory.swift b/Sources/WalletConnectSync/SyncClientFactory.swift new file mode 100644 index 000000000..da91d61fe --- /dev/null +++ b/Sources/WalletConnectSync/SyncClientFactory.swift @@ -0,0 +1,32 @@ +import Foundation + +final class SyncClientFactory { + + static func create(networkInteractor: NetworkInteracting, bip44: BIP44Provider) -> SyncClient { + let keychain = KeychainStorage(serviceIdentifier: "com.walletconnect.sdk") + return create(networkInteractor: networkInteractor, bip44: bip44, keychain: keychain) + } + + static func create(networkInteractor: NetworkInteracting, bip44: BIP44Provider, keychain: KeychainStorageProtocol) -> SyncClient { + let signatureStore = SyncSignatureStore(keychain: keychain) + let kms = KeyManagementService(keychain: keychain) + let deriviationService = SyncDerivationService( + syncStorage: signatureStore, + bip44: bip44, + kms: kms + ) + let indexStore = CodableStore(defaults: UserDefaults.standard, identifier: SyncStorageIdentifiers.index.identifier) + let syncIndexStore = SyncIndexStore(store: indexStore) + let historyStore = CodableStore(defaults: UserDefaults.standard, identifier: SyncStorageIdentifiers.history.identifier) + let syncHistoryStore = SyncHistoryStore(store: historyStore) + let syncService = SyncService( + networkInteractor: networkInteractor, + derivationService: deriviationService, + signatureStore: signatureStore, + indexStore: syncIndexStore, + historyStore: syncHistoryStore, + logger: ConsoleLogger(loggingLevel: .debug) + ) + return SyncClient(syncService: syncService, syncSignatureStore: signatureStore) + } +} diff --git a/Sources/WalletConnectSync/SyncConfig.swift b/Sources/WalletConnectSync/SyncConfig.swift new file mode 100644 index 000000000..a77e2d800 --- /dev/null +++ b/Sources/WalletConnectSync/SyncConfig.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Sync { + struct Config { + let bip44: BIP44Provider + } +} diff --git a/Sources/WalletConnectSync/SyncImports.swift b/Sources/WalletConnectSync/SyncImports.swift new file mode 100644 index 000000000..338babe27 --- /dev/null +++ b/Sources/WalletConnectSync/SyncImports.swift @@ -0,0 +1,3 @@ +#if !CocoaPods +@_exported import WalletConnectSigner +#endif diff --git a/Sources/WalletConnectSync/SyncStorageIdentifiers.swift b/Sources/WalletConnectSync/SyncStorageIdentifiers.swift new file mode 100644 index 000000000..59cb06f23 --- /dev/null +++ b/Sources/WalletConnectSync/SyncStorageIdentifiers.swift @@ -0,0 +1,18 @@ +import Foundation + +enum SyncStorageIdentifiers { + case index + case history + case object(store: String) + + var identifier: String { + switch self { + case .index: + return "com.walletconnect.sync.index" + case .history: + return "com.walletconnect.sync.history" + case .object(let store): + return "com.walletconnect.sync.object.\(store)" + } + } +} diff --git a/Sources/WalletConnectSync/Types/Methods/SyncDeleteMethod.swift b/Sources/WalletConnectSync/Types/Methods/SyncDeleteMethod.swift new file mode 100644 index 000000000..59a602fd3 --- /dev/null +++ b/Sources/WalletConnectSync/Types/Methods/SyncDeleteMethod.swift @@ -0,0 +1,9 @@ +import Foundation + +struct SyncDeleteMethod: ProtocolMethod { + let method: String = "wc_syncDel" + + let requestConfig = RelayConfig(tag: 5002, prompt: false, ttl: 2592000) + + let responseConfig = RelayConfig(tag: 5003, prompt: false, ttl: 2592000) +} diff --git a/Sources/WalletConnectSync/Types/Methods/SyncSetMethod.swift b/Sources/WalletConnectSync/Types/Methods/SyncSetMethod.swift new file mode 100644 index 000000000..fb69c2ad1 --- /dev/null +++ b/Sources/WalletConnectSync/Types/Methods/SyncSetMethod.swift @@ -0,0 +1,9 @@ +import Foundation + +struct SyncSetMethod: ProtocolMethod { + let method: String = "wc_syncSet" + + let requestConfig = RelayConfig(tag: 5000, prompt: false, ttl: 2592000) + + let responseConfig = RelayConfig(tag: 5001, prompt: false, ttl: 2592000) +} diff --git a/Sources/WalletConnectSync/Types/StoreMap.swift b/Sources/WalletConnectSync/Types/StoreMap.swift new file mode 100644 index 000000000..62f9ac2c7 --- /dev/null +++ b/Sources/WalletConnectSync/Types/StoreMap.swift @@ -0,0 +1,3 @@ +import Foundation + +public typealias StoreMap = Dictionary diff --git a/Sources/WalletConnectSync/Types/StoreUpdate.swift b/Sources/WalletConnectSync/Types/StoreUpdate.swift new file mode 100644 index 000000000..d0cf82ff0 --- /dev/null +++ b/Sources/WalletConnectSync/Types/StoreUpdate.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum StoreUpdate { + case set(StoreSet) + case delete(StoreDelete) +} + +public struct StoreSet: Codable, Equatable { + public let key: String + public let value: String +} + +public struct StoreDelete: Codable, Equatable { + public let key: String +} diff --git a/Sources/WalletConnectSync/Types/SyncRecord.swift b/Sources/WalletConnectSync/Types/SyncRecord.swift new file mode 100644 index 000000000..ddde0b38b --- /dev/null +++ b/Sources/WalletConnectSync/Types/SyncRecord.swift @@ -0,0 +1,7 @@ +import Foundation + +struct SyncRecord: Codable & Equatable { + let topic: String + let store: String + let account: Account +} diff --git a/Sources/Chat/Extensions/Dictionary.swift b/Sources/WalletConnectUtils/Extensions/Dictionary.swift similarity index 100% rename from Sources/Chat/Extensions/Dictionary.swift rename to Sources/WalletConnectUtils/Extensions/Dictionary.swift diff --git a/Sources/WalletConnectUtils/Extensions/String.swift b/Sources/WalletConnectUtils/Extensions/String.swift index 2133ff6cc..9587a1687 100644 --- a/Sources/WalletConnectUtils/Extensions/String.swift +++ b/Sources/WalletConnectUtils/Extensions/String.swift @@ -2,10 +2,6 @@ import Foundation public extension String { - enum Errors: Error { - case notAnURL - } - func toHexEncodedString(uppercase: Bool = true, prefix: String = "", separator: String = "") -> String { return unicodeScalars.map { prefix + .init($0.value, radix: 16, uppercase: uppercase) } .joined(separator: separator) } @@ -15,20 +11,12 @@ public extension String { return keyData.toHexString() } - init(rawRepresentation data: D) throws where D: ContiguousBytes { - let bytes = data.withUnsafeBytes { Data(Array($0)) } - guard let string = String(data: bytes, encoding: .utf8) else { - fatalError() // FIXME: Throw error - } - self = string - } - - var rawRepresentation: Data { - self.data(using: .utf8) ?? Data() - } - func asURL() throws -> URL { guard let url = URL(string: self) else { throw Errors.notAnURL } return url } } + +fileprivate enum Errors: Error { + case notAnURL +} diff --git a/Sources/WalletConnectUtils/KeyedDatabase.swift b/Sources/WalletConnectUtils/KeyedDatabase.swift new file mode 100644 index 000000000..0deffcc8d --- /dev/null +++ b/Sources/WalletConnectUtils/KeyedDatabase.swift @@ -0,0 +1,102 @@ +import Foundation + +public protocol DatabaseObject: Codable & Equatable { + var databaseId: String { get } +} + +public class KeyedDatabase where Element: DatabaseObject { + + public typealias Index = [String: [String: Element]] + + public var index: Index = [:] { + didSet { + guard oldValue != index else { return } + set(index, for: identifier) + onUpdate?() + } + } + + private let storage: KeyValueStorage + private let identifier: String + + public var onUpdate: (() -> Void)? + + public init(storage: KeyValueStorage, identifier: String) { + self.storage = storage + self.identifier = identifier + + initializeIndex() + } + + public func getAll() -> [Element] { + return index.values.reduce([]) { result, map in + return result + map.values + } + } + + public func getAll(for key: String) -> [Element] { + return index[key].map { Array($0.values) } ?? [] + } + + public func getElement(for key: String, id: String) -> Element? { + return index[key]?[id] + } + + @discardableResult + public func set(elements: [Element], for key: String) -> Bool { + var map = index[key] ?? [:] + + for element in elements { + guard + map[element.databaseId] == nil else { continue } + map[element.databaseId] = element + } + + index[key] = map + + return true + } + + @discardableResult + public func set(element: Element, for key: String) -> Bool { + var map = index[key] ?? [:] + + guard + map[element.databaseId] == nil else { return false } + map[element.databaseId] = element + + index[key] = map + + return true + } + + @discardableResult + public func delete(id: String, for key: String) -> Bool { + var map = index[key] + + guard + map?[id] != nil else { return false } + map?[id] = nil + + index[key] = map + + return true + } +} + +private extension KeyedDatabase { + + func initializeIndex() { + guard + let data = storage.object(forKey: identifier) as? Data, + let decoded = try? JSONDecoder().decode(Index.self, from: data) + else { return } + + index = decoded + } + + func set(_ value: Index, for key: String) { + let data = try! JSONEncoder().encode(value) + storage.set(data, forKey: key) + } +} diff --git a/Sources/Web3Inbox/Web3Inbox.swift b/Sources/Web3Inbox/Web3Inbox.swift index 32a1a87fb..e2555049e 100644 --- a/Sources/Web3Inbox/Web3Inbox.swift +++ b/Sources/Web3Inbox/Web3Inbox.swift @@ -17,13 +17,17 @@ public final class Web3Inbox { private init() { } /// Web3Inbox instance config method - /// - Parameters: - /// - account: Web3Inbox initial account - static public func configure(account: Account, config: [ConfigParam: Bool] = [:], onSign: @escaping SigningCallback, environment: APNSEnvironment) { + static public func configure( + account: Account, + bip44: BIP44Provider, + config: [ConfigParam: Bool] = [:], + environment: APNSEnvironment, + onSign: @escaping SigningCallback + ) { Web3Inbox.account = account Web3Inbox.config = config Web3Inbox.onSign = onSign - Chat.configure() + Chat.configure(bip44: bip44) Push.configure(environment: environment) } } diff --git a/Sources/Web3Modal/Environment+ProjectId.swift b/Sources/Web3Modal/Environment+ProjectId.swift new file mode 100644 index 000000000..30e272aab --- /dev/null +++ b/Sources/Web3Modal/Environment+ProjectId.swift @@ -0,0 +1,12 @@ +import SwiftUI + +private struct ProjectIdKey: EnvironmentKey { + static let defaultValue: String = "" +} + +extension EnvironmentValues { + var projectId: String { + get { self[ProjectIdKey.self] } + set { self[ProjectIdKey.self] = newValue } + } +} diff --git a/Sources/Web3Modal/Extensions/View+Backport.swift b/Sources/Web3Modal/Extensions/View+Backport.swift index 1946cd60c..ccfd93ad4 100644 --- a/Sources/Web3Modal/Extensions/View+Backport.swift +++ b/Sources/Web3Modal/Extensions/View+Backport.swift @@ -1,5 +1,5 @@ -import SwiftUI import Combine +import SwiftUI extension View { /// A backwards compatible wrapper for iOS 14 `onChange` @@ -7,7 +7,7 @@ extension View { if #available(iOS 14.0, *) { self.onChange(of: value, perform: perform) } else { - self.onReceive(Just(value)) { (value) in + self.onReceive(Just(value)) { value in perform(value) } } diff --git a/Sources/Web3Modal/Modal/Modal+Previews.swift b/Sources/Web3Modal/Modal/Modal+Previews.swift new file mode 100644 index 000000000..748a4a8f1 --- /dev/null +++ b/Sources/Web3Modal/Modal/Modal+Previews.swift @@ -0,0 +1,55 @@ +#if DEBUG + +import SwiftUI + +class WebSocketMock: WebSocketConnecting { + var request: URLRequest = .init(url: URL(string: "wss://relay.walletconnect.com")!) + + var onText: ((String) -> Void)? + var onConnect: (() -> Void)? + var onDisconnect: ((Error?) -> Void)? + var sendCallCount: Int = 0 + var isConnected: Bool = false + + func connect() {} + func disconnect() {} + func write(string: String, completion: (() -> Void)?) {} +} + +class WebSocketFactoryMock: WebSocketFactory { + func create(with url: URL) -> WebSocketConnecting { + WebSocketMock() + } +} + +@available(iOS 14.0, *) +struct ModalContainerView_Previews: PreviewProvider { + + static var previews: some View { + Content() + .previewLayout(.sizeThatFits) + } + + struct Content: View { + + init() { + + let projectId = "9bfe94c9cbf74aaa0597094ef561f703" + let metadata = AppMetadata( + name: "Showcase App", + description: "Showcase description", + url: "example.wallet", + icons: ["https://avatars.githubusercontent.com/u/37784886"] + ) + + Networking.configure(projectId: projectId, socketFactory: WebSocketFactoryMock()) + Web3Modal.configure(projectId: projectId, metadata: metadata) + } + + var body: some View { + ModalContainerView() + } + } +} + +#endif diff --git a/Sources/Web3Modal/Modal/ModalContainerView.swift b/Sources/Web3Modal/Modal/ModalContainerView.swift index 5722cdd3d..c7583f14e 100644 --- a/Sources/Web3Modal/Modal/ModalContainerView.swift +++ b/Sources/Web3Modal/Modal/ModalContainerView.swift @@ -1,25 +1,15 @@ import SwiftUI -import WalletConnectPairing +@available(iOS 14.0, *) public struct ModalContainerView: View { @Environment(\.presentationMode) var presentationMode @State var showModal: Bool = false - - let projectId: String - let metadata: AppMetadata - let webSocketFactory: WebSocketFactory - - public init(projectId: String, metadata: AppMetadata, webSocketFactory: WebSocketFactory) { - self.projectId = projectId - self.metadata = metadata - self.webSocketFactory = webSocketFactory - } - + public var body: some View { - VStack(spacing: 0) { + VStack(spacing: -10) { Color.thickOverlay .colorScheme(.light) @@ -33,11 +23,12 @@ public struct ModalContainerView: View { ModalSheet( viewModel: .init( isShown: $showModal, - projectId: projectId, - interactor: DefaultModalSheetInteractor(projectId: projectId, metadata: metadata, webSocketFactory: webSocketFactory) - )) - .transition(.move(edge: .bottom)) - .animation(.spring(), value: showModal) + interactor: DefaultModalSheetInteractor() + ) + ) + .environment(\.projectId, Web3Modal.config.projectId) + .transition(.move(edge: .bottom)) + .animation(.spring(), value: showModal) } } .edgesIgnoringSafeArea(.all) diff --git a/Sources/Web3Modal/Modal/ModalInteractor.swift b/Sources/Web3Modal/Modal/ModalInteractor.swift index 4d2ba5af6..30cdf30e6 100644 --- a/Sources/Web3Modal/Modal/ModalInteractor.swift +++ b/Sources/Web3Modal/Modal/ModalInteractor.swift @@ -1,57 +1,32 @@ -import WalletConnectPairing -import WalletConnectSign + import Combine +import Foundation protocol ModalSheetInteractor { func getListings() async throws -> [Listing] - func connect() async throws -> WalletConnectURI + func createPairingAndConnect() async throws -> WalletConnectURI? var sessionSettlePublisher: AnyPublisher { get } + var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { get } } final class DefaultModalSheetInteractor: ModalSheetInteractor { - let projectId: String - let metadata: AppMetadata - let socketFactory: WebSocketFactory - - lazy var sessionSettlePublisher: AnyPublisher = Sign.instance.sessionSettlePublisher - init(projectId: String, metadata: AppMetadata, webSocketFactory: WebSocketFactory) { - self.projectId = projectId - self.metadata = metadata - self.socketFactory = webSocketFactory - - Pair.configure(metadata: metadata) - Networking.configure(projectId: projectId, socketFactory: socketFactory) - } + lazy var sessionSettlePublisher: AnyPublisher = Web3Modal.instance.sessionSettlePublisher + lazy var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> = Web3Modal.instance.sessionRejectionPublisher func getListings() async throws -> [Listing] { let httpClient = HTTPNetworkClient(host: "explorer-api.walletconnect.com") let response = try await httpClient.request( ListingsResponse.self, - at: ExplorerAPI.getListings(projectId: projectId) + at: ExplorerAPI.getListings(projectId: Web3Modal.config.projectId) ) return response.listings.values.compactMap { $0 } } - func connect() async throws -> WalletConnectURI { - - let uri = try await Pair.instance.create() - - let methods: Set = ["eth_sendTransaction", "personal_sign", "eth_signTypedData"] - let blockchains: Set = [Blockchain("eip155:1")!] - let namespaces: [String: ProposalNamespace] = [ - "eip155": ProposalNamespace( - chains: blockchains, - methods: methods, - events: [] - ) - ] - - try await Sign.instance.connect(requiredNamespaces: namespaces, topic: uri.topic) - - return uri + func createPairingAndConnect() async throws -> WalletConnectURI? { + try await Web3Modal.instance.connect(topic: nil) } } diff --git a/Sources/Web3Modal/Modal/ModalSheet+Previews.swift b/Sources/Web3Modal/Modal/ModalSheet+Previews.swift deleted file mode 100644 index 161c15bb0..000000000 --- a/Sources/Web3Modal/Modal/ModalSheet+Previews.swift +++ /dev/null @@ -1,51 +0,0 @@ -#if DEBUG - -import SwiftUI -import WalletConnectPairing - -class WebSocketMock: WebSocketConnecting { - var request: URLRequest = .init(url: URL(string: "wss://relay.walletconnect.com")!) - - var onText: ((String) -> Void)? - var onConnect: (() -> Void)? - var onDisconnect: ((Error?) -> Void)? - var sendCallCount: Int = 0 - var isConnected: Bool = false - - func connect() {} - func disconnect() {} - func write(string: String, completion: (() -> Void)?) {} -} - -class WebSocketFactoryMock: WebSocketFactory { - func create(with url: URL) -> WebSocketConnecting { - WebSocketMock() - } -} - -struct ModalSheet_Previews: PreviewProvider { - static let projectId = "9bfe94c9cbf74aaa0597094ef561f703" - static let metadata = AppMetadata( - name: "Showcase App", - description: "Showcase description", - url: "example.wallet", - icons: ["https://avatars.githubusercontent.com/u/37784886"] - ) - - static var previews: some View { - ModalSheet( - viewModel: .init( - isShown: .constant(true), - projectId: projectId, - interactor: DefaultModalSheetInteractor( - projectId: projectId, - metadata: metadata, - webSocketFactory: WebSocketFactoryMock() - ) - ) - ) - .previewLayout(.sizeThatFits) - } -} - -#endif diff --git a/Sources/Web3Modal/Modal/ModalSheet.swift b/Sources/Web3Modal/Modal/ModalSheet.swift index 4d4f42bf8..fe7f8d63f 100644 --- a/Sources/Web3Modal/Modal/ModalSheet.swift +++ b/Sources/Web3Modal/Modal/ModalSheet.swift @@ -1,7 +1,6 @@ import SwiftUI public struct ModalSheet: View { - @ObservedObject var viewModel: ModalViewModel public var body: some View { @@ -17,10 +16,11 @@ public struct ModalSheet: View { .cornerRadius(30, corners: [.topLeft, .topRight]) } .padding(.bottom, 40) + .edgesIgnoringSafeArea(.bottom) .onAppear { Task { - await viewModel.createURI() await viewModel.fetchWallets() + await viewModel.createURI() } } .background( @@ -31,6 +31,7 @@ public struct ModalSheet: View { Color.background1 } ) + .toastView(toast: $viewModel.toast) } private func modalHeader() -> some View { @@ -55,16 +56,16 @@ public struct ModalSheet: View { private func contentHeader() -> some View { HStack(spacing: 0) { - if viewModel.destination != .wallets { + if viewModel.destination != .welcome { backButton() } Spacer() switch viewModel.destination { - case .wallets: + case .welcome: qrButton() - case .qr: + case .qr, .walletDetail: copyButton() default: EmptyView() @@ -84,81 +85,52 @@ public struct ModalSheet: View { } @ViewBuilder - private func content() -> some View { - switch viewModel.destination { - case .wallets: - ZStack { - VStack { - HStack { - ForEach(0..<4) { wallet in - gridItem(for: wallet) - } - } - HStack { - ForEach(4..<7) { wallet in - gridItem(for: wallet) - } - } - } - - Spacer().frame(height: 200) - } - case .help: - WhatIsWalletView() - case .qr: - VStack { - if let uri = viewModel.uri { - QRCodeView(uri: uri) - } else { - ActivityIndicator(isAnimating: .constant(true), style: .large) - } - } + private func welcome() -> some View { + if #available(iOS 14.0, *) { + WalletList( + wallets: .init(get: { + viewModel.wallets + }, set: { _ in }), + destination: .init(get: { + viewModel.destination + }, set: { _ in }), + navigateTo: viewModel.navigateTo(_:), + onListingTap: viewModel.onListingTap(_:) + ) } } - @ViewBuilder - private func gridItem(for index: Int) -> some View { - let wallet: Listing? = viewModel.wallets[safe: index] - - if #available(iOS 14.0, *) { - VStack { - AsyncImage(url: viewModel.imageUrl(for: wallet)) { image in - image - .resizable() - .scaledToFit() - } placeholder: { - Color - .foreground3 - .frame(width: 60, height: 60) - } - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(.gray.opacity(0.4), lineWidth: 1) - ) - - Text(wallet?.name ?? "WalletName") - .font(.system(size: 12)) - .foregroundColor(.foreground1) - .padding(.horizontal, 12) - .fixedSize(horizontal: true, vertical: true) - - Text("RECENT") - .opacity(0) - .font(.system(size: 10)) - .foregroundColor(.foreground3) - .padding(.horizontal, 12) - } - .redacted(reason: wallet == nil ? .placeholder : []) - .frame(maxWidth: 80, maxHeight: 96) - .onTapGesture { - Task { - await viewModel.onWalletTapped(index: index) - } + private func qrCode() -> some View { + VStack { + if let uri = viewModel.uri { + QRCodeView(uri: uri) + } else { + ActivityIndicator(isAnimating: .constant(true), style: .large) } } } + @ViewBuilder + private func content() -> some View { + switch viewModel.destination { + case .welcome, + .walletDetail, + .viewAll: + welcome() + case .help: + WhatIsWalletView(navigateTo: viewModel.navigateTo(_:)) + case .qr: + qrCode() + case .getWallet: + GetAWalletView( + wallets: Array(viewModel.wallets.prefix(6)), + onTap: viewModel.onGetWalletTap(_:) + ) + } + } +} + +extension ModalSheet { private func helpButton() -> some View { Button(action: { withAnimation { diff --git a/Sources/Web3Modal/Modal/ModalViewModel.swift b/Sources/Web3Modal/Modal/ModalViewModel.swift index 921e9b260..7f48aa873 100644 --- a/Sources/Web3Modal/Modal/ModalViewModel.swift +++ b/Sources/Web3Modal/Modal/ModalViewModel.swift @@ -3,118 +3,155 @@ import Combine import Foundation import SwiftUI -extension ModalSheet { - enum Destination: String, CaseIterable { - case wallets - case help - case qr +enum Destination: Equatable { + case welcome + case viewAll + case help + case qr + case walletDetail(Listing) + case getWallet - var contentTitle: String { - switch self { - case .wallets: - return "Connect your wallet" - case .qr: - return "Scan the code" - case .help: - return "What is a wallet?" - } + var contentTitle: String { + switch self { + case .welcome: + return "Connect your wallet" + case .viewAll: + return "View all" + case .qr: + return "Scan the code" + case .help: + return "What is a wallet?" + case .getWallet: + return "Get a wallet" + case let .walletDetail(wallet): + return wallet.name } } +} + +final class ModalViewModel: ObservableObject { + var isShown: Binding + let interactor: ModalSheetInteractor + let uiApplicationWrapper: UIApplicationWrapper - final class ModalViewModel: ObservableObject { - @Published private(set) var isShown: Binding - private let projectId: String - private let interactor: ModalSheetInteractor - private let uiApplicationWrapper: UIApplicationWrapper - - private var disposeBag = Set() - private var deeplinkUri: String? - - @Published private(set) var uri: String? - @Published private(set) var destination: Destination = .wallets - @Published private(set) var errorMessage: String? - @Published private(set) var wallets: [Listing] = [] - - init( - isShown: Binding, - projectId: String, - interactor: ModalSheetInteractor, - uiApplicationWrapper: UIApplicationWrapper = .live - ) { - self.isShown = isShown - self.interactor = interactor - self.projectId = projectId - self.uiApplicationWrapper = uiApplicationWrapper + @Published private(set) var destinationStack: [Destination] = [.welcome] + @Published private(set) var uri: String? + @Published private(set) var wallets: [Listing] = [] + + @Published var toast: Toast? + + var destination: Destination { + destinationStack.last! + } + + private var disposeBag = Set() + private var deeplinkUri: String? + + init( + isShown: Binding, + interactor: ModalSheetInteractor, + uiApplicationWrapper: UIApplicationWrapper = .live + ) { + self.isShown = isShown + self.interactor = interactor + self.uiApplicationWrapper = uiApplicationWrapper - interactor.sessionSettlePublisher - .receive(on: DispatchQueue.main) - .sink { sessions in - print(sessions) - isShown.wrappedValue = false - } - .store(in: &disposeBag) - } + interactor.sessionSettlePublisher + .receive(on: DispatchQueue.main) + .sink { sessions in + print(sessions) +// isShown.wrappedValue = false + self.toast = Toast(style: .success, message: "Session estabilished", duration: 15) + } + .store(in: &disposeBag) - @MainActor - func fetchWallets() async { - do { - let wallets = try await interactor.getListings() - // Small deliberate delay to ensure animations execute properly - try await Task.sleep(nanoseconds: 500_000_000) + interactor.sessionRejectionPublisher + .receive(on: DispatchQueue.main) + .sink { (proposal, reason) in - withAnimation { - self.wallets = wallets.sorted { $0.order < $1.order } + print(reason) + self.toast = Toast(style: .error, message: reason.message) + + Task { + await self.createURI() } - } catch { - print(error) - errorMessage = error.localizedDescription } - } + .store(in: &disposeBag) + } - @MainActor - func createURI() async { - do { - let wcUri = try await interactor.connect() - uri = wcUri.absoluteString - deeplinkUri = wcUri.deeplinkUri - } catch { - print(error) - errorMessage = error.localizedDescription + @MainActor + func createURI() async { + do { + guard let wcUri = try await interactor.createPairingAndConnect() else { + toast = Toast(style: .error, message: "Failed to create pairing") + return } + uri = wcUri.absoluteString + deeplinkUri = wcUri.deeplinkUri + } catch { + print(error) + toast = Toast(style: .error, message: error.localizedDescription) } + } - func navigateTo(_ destination: Destination) { - self.destination = destination - } - - func onBackButton() { - destination = .wallets - } - - func onCopyButton() { - UIPasteboard.general.string = uri - } + func navigateTo(_ destination: Destination) { + guard self.destination != destination else { return } + destinationStack.append(destination) + } + + func onListingTap(_ listing: Listing) { + navigateToDeepLink( + universalLink: listing.mobile.universal ?? "", + nativeLink: listing.mobile.native ?? "" + ) + } + + func onGetWalletTap(_ listing: Listing) { + guard + let storeLinkString = listing.app.ios, + let storeLink = URL(string: storeLinkString) + else { return } - func onWalletTapped(index: Int) { - guard let wallet = wallets[safe: index] else { return } - - navigateToDeepLink( - universalLink: wallet.mobile.universal ?? "", - nativeLink: wallet.mobile.native ?? "" - ) - } + uiApplicationWrapper.openURL(storeLink) + } + + func onBackButton() { + guard destinationStack.count != 1 else { return } + _ = destinationStack.popLast() + } - func imageUrl(for listing: Listing?) -> URL? { - guard let listing = listing else { return nil } - - let urlString = "https://explorer-api.walletconnect.com/v3/logo/md/\(listing.imageId)?projectId=\(projectId)" - - return URL(string: urlString) + func onCopyButton() { + UIPasteboard.general.string = uri + toast = Toast(style: .info, message: "URI copied into clipboard") + } + + @MainActor + func fetchWallets() async { + do { + let wallets = try await interactor.getListings() + // Small deliberate delay to ensure animations execute properly + try await Task.sleep(nanoseconds: 500_000_000) + + withAnimation { + self.wallets = wallets.sorted { + guard let lhs = $0.order else { + return false + } + + guard let rhs = $1.order else { + return true + } + + return lhs < rhs + } + } + } catch { + toast = Toast(style: .error, message: error.localizedDescription) } } } -private extension ModalSheet.ModalViewModel { +private extension ModalViewModel { enum Errors: Error { case noWalletLinkFound } @@ -132,9 +169,7 @@ private extension ModalSheet.ModalViewModel { throw Errors.noWalletLinkFound } } catch { - let alertController = UIAlertController(title: "Unable to open the app", message: nil, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - UIApplication.shared.windows.first?.rootViewController?.present(alertController, animated: true, completion: nil) + toast = Toast(style: .error, message: error.localizedDescription) } } diff --git a/Sources/Web3Modal/Modal/Screens/GetAWalletView.swift b/Sources/Web3Modal/Modal/Screens/GetAWalletView.swift new file mode 100644 index 000000000..0dba7c792 --- /dev/null +++ b/Sources/Web3Modal/Modal/Screens/GetAWalletView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct GetAWalletView: View { + + let wallets: [Listing] + let onTap: (Listing) -> Void + + var body: some View { + List { + ForEach(wallets) { wallet in + Button { + onTap(wallet) + } label: { + + HStack { + WalletImage(wallet: wallet) + .frame(width: 40, height: 40) + + Text(wallet.name) + .font(.system(size: 16, weight: .medium)) + .padding(.horizontal) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(.footnote).weight(.semibold)) + } + } + } + } + .frame(height: 500) + .listStyle(.plain) + } +} diff --git a/Sources/Web3Modal/Modal/Screens/QRCodeView.swift b/Sources/Web3Modal/Modal/Screens/QRCodeView.swift index 8ca2a6ca6..17f98f3d6 100644 --- a/Sources/Web3Modal/Modal/Screens/QRCodeView.swift +++ b/Sources/Web3Modal/Modal/Screens/QRCodeView.swift @@ -1,41 +1,82 @@ -import SwiftUI import QRCode +import SwiftUI struct QRCodeView: View { - - @State var doc: QRCode.Document! - @Environment(\.colorScheme) var colorScheme: ColorScheme @State var uri: String + @State var index: Int = 0 + + var foreground1: UIColor { + UIColor(.foreground1).resolvedColor( + with: UITraitCollection( + userInterfaceStyle: colorScheme == .dark ? .dark : .light + ) + ) + } + + var background1: UIColor { + UIColor(.background1).resolvedColor( + with: UITraitCollection( + userInterfaceStyle: colorScheme == .dark ? .dark : .light + ) + ) + } + var body: some View { - QRCodeViewUI( + render( content: uri, - errorCorrection: .quantize, - foregroundColor: AssetColor.background1.uiColor.cgColor, - backgroundColor: AssetColor.foreground1.uiColor.cgColor, - pixelStyle: QRCode.PixelShape.Vertical( - insetFraction: 0.2, - cornerRadiusFraction: 1 - ), - eyeStyle: QRCode.EyeShape.Squircle(), - logoTemplate: QRCode.LogoTemplate( - image: Asset.wc_logo.uiImage.cgImage!, - path: CGPath( - rect: CGRect(x: 0.35, y: 0.3875, width: 0.30, height: 0.225), - transform: nil - ) + size: .init( + width: UIScreen.main.bounds.width - 40, + height: UIScreen.main.bounds.width - 40 + ) + ) + .colorScheme(.dark) + } + + private func render(content: String, size: CGSize) -> Image { + let doc = QRCode.Document( + utf8String: content, + errorCorrection: .quantize + ) + doc.design.shape.eye = QRCode.EyeShape.Squircle() + doc.design.shape.onPixels = QRCode.PixelShape.Vertical( + insetFraction: 0.2, + cornerRadiusFraction: 1 + ) + + doc.design.style.eye = QRCode.FillStyle.Solid(foreground1.cgColor) + doc.design.style.pupil = QRCode.FillStyle.Solid(foreground1.cgColor) + doc.design.style.onPixels = QRCode.FillStyle.Solid(foreground1.cgColor) + doc.design.style.background = QRCode.FillStyle.Solid(background1.cgColor) + + doc.logoTemplate = QRCode.LogoTemplate( + image: Asset.wc_logo.uiImage.cgImage!, + path: CGPath( + rect: CGRect(x: 0.35, y: 0.3875, width: 0.30, height: 0.225), + transform: nil ) ) - .frame(height: UIScreen.main.bounds.width) + + return doc.imageUI( + size, label: Text("QR code with URI") + )! + } +} + +extension UIColor { + func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { rendererContext in + self.setFill() + rendererContext.fill(CGRect(origin: .zero, size: size)) + } } } struct QRCodeView_Previews: PreviewProvider { - - static let stubUri: String = Array(repeating: ["a", "b", "c", "1", "2", "3"], count: 50) - .flatMap({ $0 }) + static let stubUri: String = Array(repeating: ["a", "b", "c", "1", "2", "3"], count: 10) + .flatMap { $0 } .shuffled() .joined() diff --git a/Sources/Web3Modal/Modal/Screens/WalletList.swift b/Sources/Web3Modal/Modal/Screens/WalletList.swift new file mode 100644 index 000000000..e918e474a --- /dev/null +++ b/Sources/Web3Modal/Modal/Screens/WalletList.swift @@ -0,0 +1,186 @@ +import SwiftUI + +@available(iOS 14.0, *) +struct WalletList: View { + @Namespace var namespace + + @Binding var wallets: [Listing] + @Binding var destination: Destination + @State var retryButtonShown: Bool = false + + var navigateTo: (Destination) -> Void + var onListingTap: (Listing) -> Void + + var body: some View { + content() + } + + @ViewBuilder + private func content() -> some View { + switch destination { + case .welcome: + initialList() + case .viewAll: + viewAll() + case let .walletDetail(wallet): + walletDetail(wallet) + default: + EmptyView() + } + } + + private func initialList() -> some View { + ZStack { + VStack { + HStack { + ForEach(0..<4) { wallet in + gridItem(for: wallet) + } + } + HStack { + ForEach(4..<7) { wallet in + gridItem(for: wallet) + } + + viewAllItem() + .onTapGesture { + navigateTo(.viewAll) + } + } + } + + Spacer().frame(height: 200) + } + } + + private func viewAll() -> some View { + ScrollView(.vertical) { + VStack(alignment: .leading) { + ForEach(Array(stride(from: 0, to: wallets.count, by: 4)), id: \.self) { row in + HStack { + ForEach(row..<(row + 4), id: \.self) { index in + if wallets.indices.contains(index) { + gridItem(for: index) + } + } + } + } + } + } + } + + @ViewBuilder + func viewAllItem() -> some View { + VStack { + VStack(spacing: 3) { + HStack(spacing: 3) { + ForEach(7..<9) { index in + WalletImage(wallet: wallets[safe: index]) + .cornerRadius(8) + .aspectRatio(1, contentMode: .fit) + } + } + .padding(.horizontal, 5) + + HStack(spacing: 3) { + ForEach(9..<11) { index in + WalletImage(wallet: wallets[safe: index]) + .cornerRadius(8) + .aspectRatio(1, contentMode: .fit) + } + } + .padding(.horizontal, 5) + } + .padding(.vertical, 3) + .frame(width: 60, height: 60) + .background(Color.background2) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.gray.opacity(0.4), lineWidth: 1) + ) + + Text("View All") + .font(.system(size: 12)) + .foregroundColor(.foreground1) + .padding(.horizontal, 12) + .fixedSize(horizontal: true, vertical: true) + + Spacer() + } + .frame(maxWidth: 80, maxHeight: 96) + } + + @ViewBuilder + func gridItem(for index: Int) -> some View { + let wallet: Listing? = wallets[safe: index] + + VStack { + WalletImage(wallet: wallet) + .frame(width: 60, height: 60) + .matchedGeometryEffect(id: index, in: namespace) + + Text(wallet?.name ?? "WalletName") + .font(.system(size: 12)) + .foregroundColor(.foreground1) + .padding(.horizontal, 12) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.4) + + Text("RECENT") + .opacity(0) + .font(.system(size: 10)) + .foregroundColor(.foreground3) + .padding(.horizontal, 12) + } + .redacted(reason: wallet == nil ? .placeholder : []) + .frame(maxWidth: 80, maxHeight: 96) + .onTapGesture { + guard let wallet else { return } + + navigateTo(.walletDetail(wallet)) + } + } + + private func walletDetail(_ wallet: Listing) -> some View { + VStack(spacing: 8) { + WalletImage(wallet: wallet, size: .large) + .frame(maxWidth: 96, maxHeight: 96) + + Text("Continue in \(wallet.name)...") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.foreground1) + + Text("Accept connection request in the app") + .font(.system(size: 14)) + .foregroundColor(.foreground3) + + if retryButtonShown { + Button { + onListingTap(wallet) + } label: { + HStack { + Text("Try Again") + Image("external_link", bundle: .module) + } + } + .buttonStyle(W3MButtonStyle()) + .padding() + } + } + .padding() + .onAppear { + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + onListingTap(wallet) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + retryButtonShown = true + } + } + .onDisappear { + retryButtonShown = false + } + } +} diff --git a/Sources/Web3Modal/Modal/Screens/WhatIsWalletView.swift b/Sources/Web3Modal/Modal/Screens/WhatIsWalletView.swift index 1119fc8eb..7c75d549b 100644 --- a/Sources/Web3Modal/Modal/Screens/WhatIsWalletView.swift +++ b/Sources/Web3Modal/Modal/Screens/WhatIsWalletView.swift @@ -2,6 +2,8 @@ import SwiftUI struct WhatIsWalletView: View { + var navigateTo: (Destination) -> Void + var body: some View { VStack(spacing: 10) { @@ -22,13 +24,17 @@ struct WhatIsWalletView: View { ) HStack { - Button(action: {}) { + Button(action: { + navigateTo(.getWallet) + }) { HStack { Image("wallet", bundle: .module) Text("Get a Wallet") } } - Button(action: {}) { + Button(action: { + + }) { HStack { Text("Learn More") Image("external_link", bundle: .module) @@ -75,6 +81,6 @@ struct WhatIsWalletView_Previews: PreviewProvider { static var previews: some View { - WhatIsWalletView() + WhatIsWalletView(navigateTo: { _ in}) } } diff --git a/Sources/Web3Modal/Networking/Explorer/ExplorerAPI.swift b/Sources/Web3Modal/Networking/Explorer/ExplorerAPI.swift index b26d84662..1f37011b3 100644 --- a/Sources/Web3Modal/Networking/Explorer/ExplorerAPI.swift +++ b/Sources/Web3Modal/Networking/Explorer/ExplorerAPI.swift @@ -3,19 +3,19 @@ import HTTPClient enum ExplorerAPI: HTTPService { case getListings(projectId: String) - + var path: String { switch self { case .getListings: return "/w3m/v1/getiOSListings" } } - + var method: HTTPMethod { switch self { case .getListings: return .get } } - + var body: Data? { nil } @@ -26,11 +26,11 @@ enum ExplorerAPI: HTTPService { return [ "projectId": projectId, "page": "1", - "entries": "9", + "entries": "300", ] } } - + var scheme: String { return "https" } diff --git a/Sources/Web3Modal/Networking/Explorer/ListingsResponse.swift b/Sources/Web3Modal/Networking/Explorer/ListingsResponse.swift index 884943e44..8c08458a5 100644 --- a/Sources/Web3Modal/Networking/Explorer/ListingsResponse.swift +++ b/Sources/Web3Modal/Networking/Explorer/ListingsResponse.swift @@ -8,7 +8,7 @@ struct Listing: Codable, Hashable, Identifiable { let id: String let name: String let homepage: String - let order: Int + let order: Int? let imageId: String let app: App let mobile: Mobile diff --git a/Sources/Web3Modal/UI/ActivityIndicator.swift b/Sources/Web3Modal/UI/Common/ActivityIndicator.swift similarity index 100% rename from Sources/Web3Modal/UI/ActivityIndicator.swift rename to Sources/Web3Modal/UI/Common/ActivityIndicator.swift diff --git a/Sources/Web3Modal/UI/AsyncImage.swift b/Sources/Web3Modal/UI/Common/AsyncImage.swift similarity index 100% rename from Sources/Web3Modal/UI/AsyncImage.swift rename to Sources/Web3Modal/UI/Common/AsyncImage.swift diff --git a/Sources/Web3Modal/UI/Common/Toast.swift b/Sources/Web3Modal/UI/Common/Toast.swift new file mode 100644 index 000000000..5093db23d --- /dev/null +++ b/Sources/Web3Modal/UI/Common/Toast.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct Toast: Equatable { + var style: ToastStyle + var message: String + var duration: Double = 3 + var width: Double = .infinity +} + +enum ToastStyle { + case error + case warning + case success + case info + + var themeColor: Color { + switch self { + case .error: return Color.red + case .warning: return Color.orange + case .info: return Color.blue + case .success: return Color.green + } + } + + var iconFileName: String { + switch self { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + } + } +} + +struct ToastView: View { + var style: ToastStyle + var message: String + var width = CGFloat.infinity + var onCancelTapped: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: style.iconFileName) + .foregroundColor(style.themeColor) + Text(message) + .font(Font.caption) + .foregroundColor(.foreground1) + + Spacer(minLength: 10) + + Button { + onCancelTapped() + } label: { + Image(systemName: "xmark") + .foregroundColor(style.themeColor) + } + } + .padding() + .frame(minWidth: 0, maxWidth: width) + .background(Color.background2) + .cornerRadius(8) + .padding(.horizontal, 16) + } +} + +struct ToastModifier: ViewModifier { + @Binding var toast: Toast? + @State private var workItem: DispatchWorkItem? + + func body(content: Content) -> some View { + content + .overlay( + ZStack { + mainToastView() + .offset(y: -64) + }.animation(.spring(), value: toast) + ) + .onChangeBackported(of: toast) { _ in + showToast() + } + } + + @ViewBuilder func mainToastView() -> some View { + if let toast = toast { + VStack { + ToastView( + style: toast.style, + message: toast.message, + width: toast.width + ) { + dismissToast() + } + Spacer() + } + } + } + + private func showToast() { + guard let toast = toast else { return } + + UIImpactFeedbackGenerator(style: .light) + .impactOccurred() + + if toast.duration > 0 { + workItem?.cancel() + + let task = DispatchWorkItem { + dismissToast() + } + + workItem = task + DispatchQueue.main.asyncAfter(deadline: .now() + toast.duration, execute: task) + } + } + + private func dismissToast() { + withAnimation { + toast = nil + } + + workItem?.cancel() + workItem = nil + } +} + +extension View { + func toastView(toast: Binding) -> some View { + modifier(ToastModifier(toast: toast)) + } +} diff --git a/Sources/Web3Modal/UI/WalletImage.swift b/Sources/Web3Modal/UI/WalletImage.swift new file mode 100644 index 000000000..f14614bd5 --- /dev/null +++ b/Sources/Web3Modal/UI/WalletImage.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct WalletImage: View { + + enum Size: String { + case small = "sm" + case medium = "md" + case large = "lg" + } + + @Environment(\.projectId) var projectId + + var wallet: Listing? + var size: Size = .medium + + var body: some View { + + AsyncImage(url: imageURL(for: wallet)) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + Color.foreground3 + } + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.gray.opacity(0.4), lineWidth: 1) + ) + } + + private func imageURL(for wallet: Listing?) -> URL? { + + guard let wallet else { return nil } + + let urlString = "https://explorer-api.walletconnect.com/v3/logo/\(size.rawValue)/\(wallet.imageId)?projectId=\(projectId)" + + return URL(string: urlString) + } +} diff --git a/Sources/Web3Modal/UIKitSupport/Web3ModalSheetController.swift b/Sources/Web3Modal/UIKitSupport/Web3ModalSheetController.swift index 9890a66d2..247a4a561 100644 --- a/Sources/Web3Modal/UIKitSupport/Web3ModalSheetController.swift +++ b/Sources/Web3Modal/UIKitSupport/Web3ModalSheetController.swift @@ -1,19 +1,14 @@ import SwiftUI -import WalletConnectNetworking -import WalletConnectPairing -public class Web3ModalSheetController: UIHostingController { +@available(iOS 14.0, *) +public class Web3ModalSheetController: UIHostingController { @MainActor dynamic required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public init(projectId: String, metadata: AppMetadata, webSocketFactory: WebSocketFactory) { - let view = AnyView( - ModalContainerView(projectId: projectId, metadata: metadata, webSocketFactory: webSocketFactory) - ) - - super.init(rootView: view) + public init() { + super.init(rootView: ModalContainerView()) self.modalTransitionStyle = .crossDissolve self.modalPresentationStyle = .overFullScreen } diff --git a/Sources/Web3Modal/Web3Modal.swift b/Sources/Web3Modal/Web3Modal.swift new file mode 100644 index 000000000..d9a150a79 --- /dev/null +++ b/Sources/Web3Modal/Web3Modal.swift @@ -0,0 +1,126 @@ +import Foundation + +import UIKit + +#if SWIFT_PACKAGE +public typealias VerifyContext = WalletConnectVerify.VerifyContext +#endif + +/// Web3Modal instance wrapper +/// +/// ```Swift +/// let metadata = AppMetadata( +/// name: "Swift dapp", +/// description: "dapp", +/// url: "dapp.wallet.connect", +/// icons: ["https://my_icon.com/1"] +/// ) +/// Web3Modal.configure(metadata: metadata) +/// Web3Modal.instance.getSessions() +/// ``` +public class Web3Modal { + /// Web3Modalt client instance + public static var instance: Web3ModalClient = { + guard let config = Web3Modal.config else { + fatalError("Error - you must call Web3Modal.configure(_:) before accessing the shared instance.") + } + return Web3ModalClient( + signClient: Sign.instance, + pairingClient: Pair.instance as! (PairingClientProtocol & PairingInteracting & PairingRegisterer) + ) + }() + + struct Config { + let projectId: String + var sessionParams: SessionParams + } + + private(set) static var config: Config! + + private init() {} + + /// Wallet instance wallet config method. + /// - Parameters: + /// - metadata: App metadata + public static func configure( + projectId: String, + metadata: AppMetadata, + sessionParams: SessionParams = .default + ) { + Pair.configure(metadata: metadata) + Web3Modal.config = Web3Modal.Config(projectId: projectId, sessionParams: sessionParams) + } + + public static func set(sessionParams: SessionParams) { + Web3Modal.config.sessionParams = sessionParams + } + + public static func present(from presentingViewController: UIViewController? = nil) { + guard let vc = presentingViewController ?? topViewController() else { + assertionFailure("No controller found for presenting modal") + return + } + + if #available(iOS 14.0, *) { + let modal = Web3ModalSheetController() + vc.present(modal, animated: true) + } + } + + private static func topViewController(_ base: UIViewController? = nil) -> UIViewController? { + + let base = base ?? UIApplication + .shared + .connectedScenes + .flatMap { ($0 as? UIWindowScene)?.windows ?? [] } + .last { $0.isKeyWindow }? + .rootViewController + + if let nav = base as? UINavigationController { + return topViewController(nav.visibleViewController) + } + + if let tab = base as? UITabBarController { + if let selected = tab.selectedViewController { + return topViewController(selected) + } + } + + if let presented = base?.presentedViewController { + return topViewController(presented) + } + + return base + } +} + +public struct SessionParams { + public let requiredNamespaces: [String: ProposalNamespace] + public let optionalNamespaces: [String: ProposalNamespace]? + public let sessionProperties: [String: String]? + + public init(requiredNamespaces: [String : ProposalNamespace], optionalNamespaces: [String : ProposalNamespace]? = nil, sessionProperties: [String : String]? = nil) { + self.requiredNamespaces = requiredNamespaces + self.optionalNamespaces = optionalNamespaces + self.sessionProperties = sessionProperties + } + + public static let `default`: Self = { + let methods: Set = ["eth_sendTransaction", "personal_sign", "eth_signTypedData"] + let events: Set = ["chainChanged", "accountsChanged"] + let blockchains: Set = [Blockchain("eip155:1")!] + let namespaces: [String: ProposalNamespace] = [ + "eip155": ProposalNamespace( + chains: blockchains, + methods: methods, + events: events + ) + ] + + return SessionParams( + requiredNamespaces: namespaces, + optionalNamespaces: nil, + sessionProperties: nil + ) + }() +} diff --git a/Sources/Web3Modal/Web3ModalClient.swift b/Sources/Web3Modal/Web3ModalClient.swift new file mode 100644 index 000000000..dad05c45e --- /dev/null +++ b/Sources/Web3Modal/Web3ModalClient.swift @@ -0,0 +1,162 @@ +import Combine + +// Web3 Modal Client +/// +/// Cannot be instantiated outside of the SDK +/// +/// Access via `Web3Modal.instance` +public class Web3ModalClient { + // MARK: - Public Properties + + /// Publisher that sends sessions on every sessions update + /// + /// Event will be emited on controller and non-controller clients. + public var sessionsPublisher: AnyPublisher<[Session], Never> { + signClient.sessionsPublisher.eraseToAnyPublisher() + } + + /// Publisher that sends web socket connection status + public var socketConnectionStatusPublisher: AnyPublisher { + signClient.socketConnectionStatusPublisher.eraseToAnyPublisher() + } + + /// Publisher that sends session when one is settled + /// + /// Event is emited on proposer and responder client when both communicating peers have successfully established a session. + public var sessionSettlePublisher: AnyPublisher { + signClient.sessionSettlePublisher.eraseToAnyPublisher() + } + + /// Publisher that sends session proposal that has been rejected + /// + /// Event will be emited on dApp client only. + public var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { + signClient.sessionRejectionPublisher.eraseToAnyPublisher() + } + + /// Publisher that sends deleted session topic + /// + /// Event can be emited on any type of the client. + public var sessionDeletePublisher: AnyPublisher<(String, Reason), Never> { + signClient.sessionDeletePublisher.eraseToAnyPublisher() + } + + /// Publisher that sends response for session request + /// + /// In most cases that event will be emited on dApp client. + public var sessionResponsePublisher: AnyPublisher { + signClient.sessionResponsePublisher.eraseToAnyPublisher() + } + + // MARK: - Private Properties + + private let signClient: SignClientProtocol + private let pairingClient: PairingClientProtocol & PairingInteracting & PairingRegisterer + + init( + signClient: SignClientProtocol, + pairingClient: PairingClientProtocol & PairingInteracting & PairingRegisterer + ) { + self.signClient = signClient + self.pairingClient = pairingClient + } + + /// For creating new pairing URI + public func createPairing() async throws -> WalletConnectURI { + try await pairingClient.create() + } + + /// For proposing a session to a wallet. + /// Function will propose a session on existing pairing. + /// - Parameters: + /// - topic: pairing topic + public func connect( + topic: String? + ) async throws -> WalletConnectURI? { + if let topic = topic { + try pairingClient.validatePairingExistance(topic) + try await signClient.connect( + requiredNamespaces: Web3Modal.config.sessionParams.requiredNamespaces, + optionalNamespaces: Web3Modal.config.sessionParams.optionalNamespaces, + sessionProperties: Web3Modal.config.sessionParams.sessionProperties, + topic: topic + ) + return nil + } else { + let pairingURI = try await pairingClient.create() + try await signClient.connect( + requiredNamespaces: Web3Modal.config.sessionParams.requiredNamespaces, + optionalNamespaces: Web3Modal.config.sessionParams.optionalNamespaces, + sessionProperties: Web3Modal.config.sessionParams.sessionProperties, + topic: pairingURI.topic + ) + return pairingURI + } + } + + /// For proposing a session to a wallet. + /// Function will propose a session on existing pairing. + /// - Parameters: + /// - requiredNamespaces: required namespaces for a session + /// - topic: pairing topic + public func connect( + requiredNamespaces: [String: ProposalNamespace], + optionalNamespaces: [String: ProposalNamespace]? = nil, + sessionProperties: [String: String]? = nil, + topic: String + ) async throws { + try await signClient.connect( + requiredNamespaces: requiredNamespaces, + optionalNamespaces: optionalNamespaces, + sessionProperties: sessionProperties, + topic: topic + ) + } + + /// Ping method allows to check if peer client is online and is subscribing for given topic + /// + /// Should Error: + /// - When the session topic is not found + /// + /// - Parameters: + /// - topic: Topic of a session + public func ping(topic: String) async throws { + try await pairingClient.ping(topic: topic) + } + + /// For sending JSON-RPC requests to wallet. + /// - Parameters: + /// - params: Parameters defining request and related session + public func request(params: Request) async throws { + try await signClient.request(params: params) + } + + /// For a terminating a session + /// + /// Should Error: + /// - When the session topic is not found + /// - Parameters: + /// - topic: Session topic that you want to delete + public func disconnect(topic: String) async throws { + try await signClient.disconnect(topic: topic) + } + + /// Query sessions + /// - Returns: All sessions + public func getSessions() -> [Session] { + signClient.getSessions() + } + + /// Query pairings + /// - Returns: All pairings + public func getPairings() -> [Pairing] { + pairingClient.getPairings() + } + + /// Delete all stored data such as: pairings, sessions, keys + /// + /// - Note: Will unsubscribe from all topics + public func cleanup() async throws { + try await signClient.cleanup() + } +} diff --git a/Sources/Web3Modal/Web3ModalImports.swift b/Sources/Web3Modal/Web3ModalImports.swift new file mode 100644 index 000000000..e0ddb7a79 --- /dev/null +++ b/Sources/Web3Modal/Web3ModalImports.swift @@ -0,0 +1,4 @@ +#if !CocoaPods +@_exported import WalletConnectSign +@_exported import WalletConnectPairing +#endif diff --git a/Tests/AuthTests/Stubs/MessageSignerMock.swift b/Tests/AuthTests/Stubs/MessageSignerMock.swift index 3809c2dbb..45e77cd12 100644 --- a/Tests/AuthTests/Stubs/MessageSignerMock.swift +++ b/Tests/AuthTests/Stubs/MessageSignerMock.swift @@ -15,6 +15,9 @@ extension MessageVerifier { } struct Crypto: CryptoProvider { + func derive(entropy: Data, path: [WalletConnectSigner.DerivationPath]) -> Data { + return Data() + } func keccak256(_ data: Data) -> Data { return Data() diff --git a/Tests/ChatTests/RegistryServiceTests.swift b/Tests/ChatTests/RegistryServiceTests.swift index 949350b7c..24d2c657e 100644 --- a/Tests/ChatTests/RegistryServiceTests.swift +++ b/Tests/ChatTests/RegistryServiceTests.swift @@ -40,14 +40,7 @@ final class RegistryServiceTests: XCTestCase { ) identityClient = IdentityClient(identityService: identitySevice, identityStorage: identityStorage, logger: ConsoleLoggerMock()) - let storage = RuntimeKeyValueStorage() - let chatStorage = ChatStorage( - messageStore: .init(storage: storage, identifier: ""), - receivedInviteStore: .init(storage: storage, identifier: ""), - sentInviteStore: .init(storage: storage, identifier: ""), - threadStore: .init(storage: storage, identifier: "") - ) - resubscriptionService = ResubscriptionService(networkingInteractor: networkingInteractor, kms: kms, chatStorage: chatStorage, logger: ConsoleLoggerMock()) + resubscriptionService = ResubscriptionService(networkingInteractor: networkingInteractor, kms: kms, logger: ConsoleLoggerMock()) } func testRegister() async throws { diff --git a/Tests/RelayerTests/AuthTests/SocketAuthenticatorTests.swift b/Tests/RelayerTests/AuthTests/SocketAuthenticatorTests.swift index 60ac1390a..72dd37bc5 100644 --- a/Tests/RelayerTests/AuthTests/SocketAuthenticatorTests.swift +++ b/Tests/RelayerTests/AuthTests/SocketAuthenticatorTests.swift @@ -5,14 +5,14 @@ import WalletConnectKMS final class SocketAuthenticatorTests: XCTestCase { var clientIdStorage: ClientIdStorageMock! - var sut: SocketAuthenticator! + var sut: ClientIdAuthenticator! let expectedToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtvZEhad25lVlJTaHRhTGY4SktZa3hwREdwMXZHWm5wR21kQnBYOE0yZXh4SCIsInN1YiI6ImM0NzlmZTVkYzQ2NGU3NzFlNzhiMTkzZDIzOWE2NWI1OGQyNzhjYWQxYzM0YmZiMGI1NzE2ZTViYjUxNDkyOGUifQ.0JkxOM-FV21U7Hk-xycargj_qNRaYV2H5HYtE4GzAeVQYiKWj7YySY5AdSqtCgGzX4Gt98XWXn2kSr9rE1qvCA" override func setUp() { clientIdStorage = ClientIdStorageMock() - sut = SocketAuthenticator( + sut = ClientIdAuthenticator( clientIdStorage: clientIdStorage, - relayHost: "relay.walletconnect.com" + url: "wss://relay.walletconnect.com" ) } diff --git a/Tests/RelayerTests/DispatcherTests.swift b/Tests/RelayerTests/DispatcherTests.swift index 170bbcb37..35660c6f1 100644 --- a/Tests/RelayerTests/DispatcherTests.swift +++ b/Tests/RelayerTests/DispatcherTests.swift @@ -62,9 +62,9 @@ final class DispatcherTests: XCTestCase { networkMonitor = NetworkMonitoringMock() let keychainStorageMock = DispatcherKeychainStorageMock() let clientIdStorage = ClientIdStorage(keychain: keychainStorageMock) - let socketAuthenticator = SocketAuthenticator( + let socketAuthenticator = ClientIdAuthenticator( clientIdStorage: clientIdStorage, - relayHost: "relay.walletconnect.com" + url: "wss://relay.walletconnect.com" ) let relayUrlFactory = RelayUrlFactory( relayHost: "relay.walletconnect.com", diff --git a/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift b/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift index c2f9e7e54..2197afbaa 100644 --- a/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift +++ b/Tests/Web3ModalTests/Mocks/ModalSheetInteractorMock.swift @@ -19,11 +19,11 @@ final class ModalSheetInteractorMock: ModalSheetInteractor { self.listings = listings } - func getListings() async throws -> [Web3Modal.Listing] { + func getListings() async throws -> [Listing] { listings } - func connect() async throws -> WalletConnectURI { + func createPairingAndConnect() async throws -> WalletConnectURI? { .init(topic: "foo", symKey: "bar", relay: .init(protocol: "irn", data: nil)) } @@ -31,4 +31,19 @@ final class ModalSheetInteractorMock: ModalSheetInteractor { Result.Publisher(Session(topic: "", pairingTopic: "", peer: .stub(), namespaces: [:], expiryDate: Date())) .eraseToAnyPublisher() } + + var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { + let sessionProposal = Session.Proposal( + id: "", + pairingTopic: "", + proposer: AppMetadata(name: "", description: "", url: "", icons: []), + requiredNamespaces: [:], + optionalNamespaces: nil, + sessionProperties: nil, + proposal: SessionProposal(relays: [], proposer: Participant(publicKey: "", metadata: AppMetadata(name: "", description: "", url: "", icons: [])), requiredNamespaces: [:], optionalNamespaces: [:], sessionProperties: [:]) + ) + + return Result.Publisher((sessionProposal, SignReasonCode.userRejectedChains)) + .eraseToAnyPublisher() + } } diff --git a/Tests/Web3ModalTests/ModalViewModelTests.swift b/Tests/Web3ModalTests/ModalViewModelTests.swift index 7965a201d..fb01ebc23 100644 --- a/Tests/Web3ModalTests/ModalViewModelTests.swift +++ b/Tests/Web3ModalTests/ModalViewModelTests.swift @@ -3,7 +3,7 @@ import TestingUtils import XCTest final class ModalViewModelTests: XCTestCase { - private var sut: ModalSheet.ModalViewModel! + private var sut: ModalViewModel! private var openURLFuncTest: FuncTest! private var canOpenURLFuncTest: FuncTest! @@ -17,7 +17,6 @@ final class ModalViewModelTests: XCTestCase { sut = .init( isShown: .constant(true), - projectId: "", interactor: ModalSheetInteractorMock(listings: [ Listing(id: "1", name: "Sample App", homepage: "https://example.com", order: 1, imageId: "1", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: nil, universal: "https://example.com/universal")), Listing(id: "2", name: "Awesome App", homepage: "https://example.com/awesome", order: 2, imageId: "2", app: Listing.App(ios: "https://example.com/download-ios", mac: "https://example.com/download-mac", safari: "https://example.com/download-safari"), mobile: Listing.Mobile(native: "awesomeapp://deeplink", universal: "https://awesome.com/awesome/universal")), @@ -57,7 +56,7 @@ final class ModalViewModelTests: XCTestCase { expectation = XCTestExpectation(description: "Wait for openUrl to be called") - sut.onWalletTapped(index: 0) + sut.onListingTap(sut.wallets[0]) XCTWaiter.wait(for: [expectation], timeout: 3) @@ -68,7 +67,7 @@ final class ModalViewModelTests: XCTestCase { expectation = XCTestExpectation(description: "Wait for openUrl to be called 2nd time") - sut.onWalletTapped(index: 1) + sut.onListingTap(sut.wallets[1]) XCTWaiter.wait(for: [expectation], timeout: 3) diff --git a/Tests/Web3WalletTests/Mocks/SignClientMock.swift b/Tests/Web3WalletTests/Mocks/SignClientMock.swift index 3a9b6336d..fec7b51c4 100644 --- a/Tests/Web3WalletTests/Mocks/SignClientMock.swift +++ b/Tests/Web3WalletTests/Mocks/SignClientMock.swift @@ -4,6 +4,7 @@ import Combine @testable import WalletConnectSign final class SignClientMock: SignClientProtocol { + var approveCalled = false var rejectCalled = false var updateCalled = false @@ -13,6 +14,8 @@ final class SignClientMock: SignClientProtocol { var pairCalled = false var disconnectCalled = false var cleanupCalled = false + var connectCalled = false + var requestCalled = false private let metadata = AppMetadata(name: "", description: "", url: "", icons: []) private let request = WalletConnectSign.Request(id: .left(""), topic: "", method: "", params: "", chainId: Blockchain("eip155:1")!, expiry: nil) @@ -57,6 +60,21 @@ final class SignClientMock: SignClientProtocol { .eraseToAnyPublisher() } + var sessionRejectionPublisher: AnyPublisher<(Session.Proposal, Reason), Never> { + let sessionProposal = Session.Proposal( + id: "", + pairingTopic: "", + proposer: AppMetadata(name: "", description: "", url: "", icons: []), + requiredNamespaces: [:], + optionalNamespaces: nil, + sessionProperties: nil, + proposal: SessionProposal(relays: [], proposer: Participant(publicKey: "", metadata: AppMetadata(name: "", description: "", url: "", icons: [])), requiredNamespaces: [:], optionalNamespaces: [:], sessionProperties: [:]) + ) + + return Result.Publisher((sessionProposal, SignReasonCode.userRejectedChains)) + .eraseToAnyPublisher() + } + var sessionResponsePublisher: AnyPublisher { return Result.Publisher(.success(response)) .eraseToAnyPublisher() @@ -109,4 +127,17 @@ final class SignClientMock: SignClientProtocol { func cleanup() async throws { cleanupCalled = true } + + func connect( + requiredNamespaces: [String : WalletConnectSign.ProposalNamespace], + optionalNamespaces: [String : WalletConnectSign.ProposalNamespace]?, + sessionProperties: [String : String]?, + topic: String + ) async throws { + connectCalled = true + } + + func request(params: WalletConnectSign.Request) async throws { + requestCalled = true + } } diff --git a/WalletConnectSwiftV2.podspec b/WalletConnectSwiftV2.podspec index c224bae47..6b3b759a6 100644 --- a/WalletConnectSwiftV2.podspec +++ b/WalletConnectSwiftV2.podspec @@ -101,10 +101,22 @@ Pod::Spec.new do |spec| ss.dependency 'WalletConnectSwiftV2/WalletConnectNetworking' end + spec.subspec 'WalletConnectHistory' do |ss| + ss.source_files = 'Sources/WalletConnectHistory/**/*.{h,m,swift}' + ss.dependency 'WalletConnectSwiftV2/WalletConnectRelay' + ss.dependency 'WalletConnectSwiftV2/HTTPClient' + end + spec.subspec 'WalletConnectChat' do |ss| ss.source_files = 'Sources/Chat/**/*.{h,m,swift}' - ss.dependency 'WalletConnectSwiftV2/WalletConnectSigner' + ss.dependency 'WalletConnectSwiftV2/WalletConnectSync' ss.dependency 'WalletConnectSwiftV2/WalletConnectIdentity' + ss.dependency 'WalletConnectSwiftV2/WalletConnectHistory' + end + + spec.subspec 'WalletConnectSync' do |ss| + ss.source_files = 'Sources/WalletConnectSync/**/*.{h,m,swift}' + ss.dependency 'WalletConnectSwiftV2/WalletConnectSigner' end spec.subspec 'WalletConnectSigner' do |ss|