From 00fb1ff60af842680a27415b26bd6bccef0c3065 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Thu, 7 Nov 2024 11:10:10 +0200 Subject: [PATCH] fix(WC): Refactor dapps service to work with multiple SDKs This PR is refactoring the dapps service to avoid code duplication between SDKs and also to avoid overlapping requests/responses. It brings Browser Connect inline with Wallet Connect in terms of session management and sign transactions. New architecture: WalletConnectService becomes DAppsService. Its responsibility is to provide dapp access to the app. This is the component currently used by the UI What does it do: 1. Provide dapp APIs line connect, disconnect, session requests etc 2. Spawn app notifications on dapp events 3. Timeout requests if the dapp does not respons DAppsRequestHandler becomes DAppsModule. This component is consumed by the DAppService. Its responsibility is to aggregate all the building blocks for the dapps, but does not control any of the dapp features or consume the SDKs requests. What does it do: 1. Aggregate all the building blocks for dapps (currently known as plugins) DAppConnectionsPlugin - This component provides the session management features line connect, disconnect and provide a model with the connected dapps. SignRequestPlugin - This component provides the sign request management. It receives the sign request from the dapp, translates it to what Status understands and manages the lifecycle of the request. --- .../shared_modules/connector/controller.nim | 12 +- storybook/pages/DAppsWorkflowPage.qml | 89 +- storybook/pages/SessionRequestPage.qml | 103 ++ .../qmlTests/tests/tst_BCDAppsProvider.qml | 98 ++ .../qmlTests/tests/tst_DAppsWorkflow.qml | 247 ++--- .../qmlTests/tests/tst_SessionRequest.qml | 72 ++ .../tests/tst_SessionRequestWithAuth.qml | 4 +- .../qmlTests/tests/tst_SignRequestPlugin.qml | 268 ++++++ .../qmlTests/tests/tst_WCDAppsProvider.qml | 133 +++ .../shared/stores/BrowserConnectStore.qml | 4 + storybook/stubs/shared/stores/qmldir | 1 + .../Wallet/panels/DAppsWorkflow.qml | 9 +- .../AppLayouts/Wallet/panels/WalletHeader.qml | 40 +- .../Wallet/services/dapps/BCDappsProvider.qml | 108 ++- .../Wallet/services/dapps/DAppsModel.qml | 70 +- .../Wallet/services/dapps/DAppsModule.qml | 309 ++++++ .../services/dapps/DAppsRequestHandler.qml | 2 + .../Wallet/services/dapps/DAppsService.qml | 271 ++++++ .../services/dapps/DappsConnectorSDK.qml | 911 ++++-------------- .../Wallet/services/dapps/WCDappsProvider.qml | 86 +- .../services/dapps/WalletConnectSDK.qml | 14 +- .../services/dapps/WalletConnectSDKBase.qml | 19 +- .../dapps/plugins/DAppConnectionsPlugin.qml | 353 +++++++ .../dapps/plugins/SignRequestPlugin.qml | 671 +++++++++++++ .../services/dapps/plugins/SiweLifeCycle.qml | 3 +- .../dapps/plugins/SiweRequestPlugin.qml | 3 + .../Wallet/services/dapps/plugins/qmldir | 4 +- .../AppLayouts/Wallet/services/dapps/qmldir | 4 +- .../Wallet/services/dapps/types/Pairing.qml | 2 + .../services/dapps/types/SessionRequest.qml | 313 ++++++ .../dapps/types/SessionRequestResolved.qml | 29 +- .../dapps/types/SessionRequestResolver.qml | 78 ++ .../Wallet/services/dapps/types/qmldir | 1 + ui/app/mainui/AppMain.qml | 86 +- ui/imports/shared/panels/AnimatedText.qml | 2 +- .../walletconnect/DAppSignRequestModal.qml | 3 + .../shared/stores/BrowserConnectStore.qml | 20 +- ui/imports/utils/Global.qml | 2 +- 38 files changed, 3311 insertions(+), 1133 deletions(-) create mode 100644 storybook/pages/SessionRequestPage.qml create mode 100644 storybook/qmlTests/tests/tst_BCDAppsProvider.qml create mode 100644 storybook/qmlTests/tests/tst_SessionRequest.qml create mode 100644 storybook/qmlTests/tests/tst_SignRequestPlugin.qml create mode 100644 storybook/qmlTests/tests/tst_WCDAppsProvider.qml create mode 100644 storybook/stubs/shared/stores/BrowserConnectStore.qml create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/DAppsService.qml create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/plugins/DAppConnectionsPlugin.qml create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/plugins/SignRequestPlugin.qml create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolver.qml diff --git a/src/app/modules/shared_modules/connector/controller.nim b/src/app/modules/shared_modules/connector/controller.nim index 3b3896452a..8dd685e0c9 100644 --- a/src/app/modules/shared_modules/connector/controller.nim +++ b/src/app/modules/shared_modules/connector/controller.nim @@ -34,8 +34,8 @@ QtObject: proc approveConnectResponse*(self: Controller, payload: string, error: bool) {.signal.} proc rejectConnectResponse*(self: Controller, payload: string, error: bool) {.signal.} - proc approveTransactionResponse*(self: Controller, requestId: string, error: bool) {.signal.} - proc rejectTransactionResponse*(self: Controller, requestId: string, error: bool) {.signal.} + proc approveTransactionResponse*(self: Controller, topic: string, requestId: string, error: bool) {.signal.} + proc rejectTransactionResponse*(self: Controller, topic: string, requestId: string, error: bool) {.signal.} proc newController*(service: connector_service.Service, events: EventEmitter): Controller = new(result, delete) @@ -114,15 +114,15 @@ QtObject: result = self.service.rejectDappConnect(requestId) self.rejectConnectResponse(requestId, not result) - proc approveTransaction*(self: Controller, requestId: string, signature: string): bool {.slot.} = + proc approveTransaction*(self: Controller, sessionTopic: string, requestId: string, signature: string): bool {.slot.} = let hash = utils.createHash(signature) result = self.service.approveTransactionRequest(requestId, hash) - self.approveTransactionResponse(requestId, not result) + self.approveTransactionResponse(sessionTopic, requestId, not result) - proc rejectTransaction*(self: Controller, requestId: string): bool {.slot.} = + proc rejectTransaction*(self: Controller, sessionTopic: string, requestId: string): bool {.slot.} = result = self.service.rejectTransactionSigning(requestId) - self.rejectTransactionResponse(requestId, not result) + self.rejectTransactionResponse(sessionTopic, requestId, not result) proc disconnect*(self: Controller, dAppUrl: string): bool {.slot.} = result = self.service.recallDAppPermission(dAppUrl) diff --git a/storybook/pages/DAppsWorkflowPage.qml b/storybook/pages/DAppsWorkflowPage.qml index 3bb9a8802d..dee3c954d6 100644 --- a/storybook/pages/DAppsWorkflowPage.qml +++ b/storybook/pages/DAppsWorkflowPage.qml @@ -6,6 +6,7 @@ import Qt.labs.settings 1.0 import QtTest 1.15 import QtQml.Models 2.14 +import StatusQ 0.1 import StatusQ.Core 0.1 import StatusQ.Core.Backpressure 0.1 import StatusQ.Controls 0.1 @@ -64,13 +65,13 @@ Item { spacing: 8 - readonly property var wcService: walletConnectService + readonly property var wcService: dappsService loginType: Constants.LoginType.Biometrics selectedAccountAddress: "" model: wcService.dappsModel - accountsModel: wcService.validAccounts - networksModel: wcService.flatNetworks + accountsModel: dappModule.accountsModel + networksModel: dappModule.networksModel sessionRequestsModel: wcService.sessionRequestsModel enabled: wcService.isServiceOnline @@ -127,7 +128,7 @@ Item { Layout.preferredWidth: 20 Layout.preferredHeight: Layout.preferredWidth radius: Layout.preferredWidth / 2 - color: walletConnectService.wcSDK.sdkReady ? "green" : "red" + color: dappModule.wcSdk.sdkReady ? "green" : "red" } } @@ -166,7 +167,7 @@ Item { ListView { Layout.fillWidth: true Layout.preferredHeight: Math.min(50, contentHeight) - model: walletConnectService.sessionRequestsModel + model: dappsService.sessionRequestsModel delegate: RowLayout { StatusBaseText { text: SQUtils.Utils.elideAndFormatWalletAddress(model.topic, 6, 4) @@ -208,7 +209,7 @@ Item { NetworkFilter { id: networkFilter - flatNetworks: walletConnectService.walletRootStore.filteredFlatModel + flatNetworks: dappModule.networksModel } // spacer @@ -228,7 +229,7 @@ Item { text: "WC feature flag" checked: true onCheckedChanged: { - walletConnectService.walletConnectFeatureEnabled = checked + dappsService.walletConnectFeatureEnabled = checked } } @@ -236,7 +237,7 @@ Item { text: "Connector feature flag" checked: true onCheckedChanged: { - walletConnectService.connectorFeatureEnabled = checked + dappsService.connectorFeatureEnabled = checked } } } @@ -307,7 +308,7 @@ Item { let modals = StoryBook.InspectionUtils.findVisualsByTypeName(dappsWorkflow, "PairWCModal") if (modals.length === 1) { let buttons = StoryBook.InspectionUtils.findVisualsByTypeName(modals[0].footer, "StatusButton") - if (buttons.length === 1 && buttons[0].enabled && walletConnectService.wcSDK.sdkReady) { + if (buttons.length === 1 && buttons[0].enabled && dappModule.wcSdk.sdkReady) { d.activeTestCase = d.noTestCase buttons[0].clicked() return @@ -342,14 +343,14 @@ Item { StatusButton { text: qsTr("Reject") onClicked: { - walletConnectService.store.userAuthenticationFailed(authMockDialog.topic, authMockDialog.id) + dappModule.store.userAuthenticationFailed(authMockDialog.topic, authMockDialog.id) authMockDialog.close() } } StatusButton { text: qsTr("Authenticate") onClicked: { - walletConnectService.store.userAuthenticated(authMockDialog.topic, authMockDialog.id, "0x1234567890", "123") + dappModule.store.userAuthenticated(authMockDialog.topic, authMockDialog.id, "0x1234567890", "123") authMockDialog.close() } } @@ -357,17 +358,35 @@ Item { } } - WalletConnectService { - id: walletConnectService - - wcSDK: WalletConnectSDK { - enableSdk: settings.enableSDK + DAppsModule { + id: dappModule + wcSdk: WalletConnectSDK { + enabled: settings.enableSDK && dappsService.walletConnectFeatureEnabled projectId: projectIdText.projectId } - blockchainNetworksDown: networkFilter.selection + bcSdk: DappsConnectorSDK { + enabled: false + projectId: projectIdText.projectId + networksModel: dappModule.networksModel + accountsModel: dappModule.accountsModel + store: SharedStores.BrowserConnectStore { + signal connectRequested(string requestId, string dappJson) + signal signRequested(string requestId, string requestJson) + + signal connected(string dappJson) + signal disconnected(string dappJson) + + // Responses to user actions + signal approveConnectResponse(string id, bool error) + signal rejectConnectResponse(string id, bool error) + + signal approveTransactionResponse(string topic, string requestId, bool error) + signal rejectTransactionResponse(string topic, string requestId, bool error) + } + } store: SharedStores.DAppsStore { signal dappsListReceived(string dappsJson) signal userAuthenticated(string topic, string id, string password, string pin) @@ -485,25 +504,29 @@ Item { } } - walletRootStore: SQUtils.QObject { - property var filteredFlatModel: SortFilterProxyModel { - sourceModel: NetworksModel.flatNetworks - filters: ValueFilter { roleName: "isTest"; value: settings.testNetworks; } - } - property var accounts: customAccountsModel.count > 0 ? customAccountsModel : defaultAccountsModel - readonly property ListModel nonWatchAccounts: accounts + currenciesStore: SharedStores.CurrenciesStore {} + groupedAccountAssetsModel: GroupedAccountsAssetsModel {} + accountsModel: customAccountsModel.count > 0 ? customAccountsModel : defaultAccountsModel - readonly property SharedStores.CurrenciesStore currencyStore: SharedStores.CurrenciesStore {} - readonly property WalletStore.WalletAssetsStore walletAssetsStore: WalletStore.WalletAssetsStore { - // Silence warnings - assetsWithFilteredBalances: ListModel {} - // Name mismatch between storybook and production - readonly property var groupedAccountAssetsModel: groupedAccountsAssetsModel - } - - readonly property string selectedAddress: "" + networksModel: SortFilterProxyModel { + sourceModel: NetworksModel.flatNetworks + proxyRoles: [ + FastExpressionRole { + name: "isOnline" + expression: !networkFilter.selection.map(Number).includes(model.chainId) + expectedRoles: "chainId" + } + ] + filters: ValueFilter { roleName: "isTest"; value: settings.testNetworks; } } + } + DAppsService { + id: dappsService + + dappsModule: dappModule + accountsModel: customAccountsModel.count > 0 ? customAccountsModel : defaultAccountsModel + selectedAddress: "" onDisplayToastMessage: (message, isErr) => { if(isErr) { console.log(`Storybook.displayToastMessage(${message}, "", "warning", false, Constants.ephemeralNotificationType.danger, "")`) diff --git a/storybook/pages/SessionRequestPage.qml b/storybook/pages/SessionRequestPage.qml new file mode 100644 index 0000000000..bd849e871c --- /dev/null +++ b/storybook/pages/SessionRequestPage.qml @@ -0,0 +1,103 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import AppLayouts.Wallet.services.dapps.types 1.0 + +SplitView { + id: root + orientation: Qt.Horizontal + + readonly property string sign: "{\n\ + \"id\": 1730473461432473,\n\ + \"params\": {\n\ + \"chainId\": \"eip155:1\",\n\ + \"request\": {\n\ + \"expiryTimestamp\": 1730473761,\n\ + \"method\": \"personal_sign\",\n\ + \"params\": [\n\ + \"0x4d7920656d61696c206973206a6f686e40646f652e636f6d202d2031373330343733343631343331\",\n\ + \"0x8b6950bb8a74489a83e6a1281e3aa008f02bf368\"\n\ + ]\n\ + },\n\ + \"topic\": \"3a9a320f8fc8e7a814895b148911373ba7df58c176ddca989f0e72ea1f9b8148\",\n\ + \"verifyContext\": {\n\ + \"verified\": {\n\ + \"isScam\": false,\n\ + \"origin\": \"https://react-app.walletconnect.com\",\n\ + \"validation\": \"VALID\",\n\ + \"verifyUrl\": \"https://verify.walletconnect.org\"\n\ + }\n\ + }\n\ + }\n\ + }" + readonly property string transaction: "{\n\ + \"id\": 1730473547658704,\n\ + \"params\": {\n\ + \"chainId\": \"eip155:10\",\n\ + \"request\": {\n\ + \"expiryTimestamp\": 1730473847,\n\ + \"method\": \"eth_sendTransaction\",\n\ + \"params\": [\n\ + {\n\ + \"data\": \"0x\",\n\ + \"from\": \"0x8b6950bb8a74489a83e6a1281e3aa008f02bf368\",\n\ + \"gasLimit\": \"0x5208\",\n\ + \"gasPrice\": \"0x0f437c\",\n\ + \"nonce\": \"0x4e\",\n\ + \"to\": \"0x8b6950bb8a74489a83e6a1281e3aa008f02bf368\",\n\ + \"value\": \"0x00\"\n\ + }\n\ + ]\n\ + }\n\ + },\n\ + \"topic\": \"3a9a320f8fc8e7a814895b148911373ba7df58c176ddca989f0e72ea1f9b8148\",\n\ + \"verifyContext\": {\n\ + \"verified\": {\n\ + \"isScam\": false,\n\ + \"origin\": \"https://react-app.walletconnect.com\",\n\ + \"validation\": \"VALID\",\n\ + \"verifyUrl\": \"https://verify.walletconnect.org\"\n\ + }\n\ + }\n\ + }" + ScrollView { + SplitView.fillHeight: true + SplitView.fillWidth: true + TextArea { + id: result + text: "Result: " + JSON.stringify(SessionRequest.parse(JSON.parse(textEdit.text.replace(/\\n/g, "\n"))), undefined, 2) + readOnly: true + } + } + + ColumnLayout { + SplitView.fillHeight: true + SplitView.fillWidth: true + SplitView.preferredWidth: root.width / 2 + Label { + text: "Paste the event here to simulate the session request parsing" + font.bold: true + } + Rectangle { + Layout.fillWidth: true + height: 2 + color: "black" + } + TextArea { + id: textEdit + Layout.fillHeight: true + Layout.fillWidth: true + text: root.transaction + onTextChanged: text = JSON.stringify(JSON.parse(text.replace(/\\/g, "")), undefined, 2) + } + ComboBox { + id: comboBox + Layout.fillWidth: true + Layout.fillHeight: true + model: ["sign", "transaction"] + currentIndex: 0 + onCurrentIndexChanged: textEdit.text = root[comboBox.currentText] + } + } +} \ No newline at end of file diff --git a/storybook/qmlTests/tests/tst_BCDAppsProvider.qml b/storybook/qmlTests/tests/tst_BCDAppsProvider.qml new file mode 100644 index 0000000000..032669e6db --- /dev/null +++ b/storybook/qmlTests/tests/tst_BCDAppsProvider.qml @@ -0,0 +1,98 @@ +import QtQuick 2.15 +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps 1.0 + +Item { + id: root + + Component { + id: bcDAppsProviderComponent + BCDappsProvider { + id: bcDAppsProvider + readonly property SignalSpy connectedSpy: SignalSpy { target: bcDAppsProvider; signalName: "connected" } + readonly property SignalSpy disconnectedSpy: SignalSpy { target: bcDAppsProvider; signalName: "disconnected" } + bcSDK: WalletConnectSDKBase { + enabled: true + projectId: "" + property var activeSessions: {} + getActiveSessions: function(callback) { + callback(activeSessions) + } + } + } + } + + function buildSession(dappUrl, dappName, dappIcon, proposalId, account, chains) { + let sessionTemplate = (dappUrl, dappName, dappIcon, proposalId, eipAccount, eipChains) => { + return { + peer: { + metadata: { + description: "-", + icons: [ + dappIcon + ], + name: dappName, + url: dappUrl + } + }, + namespaces: { + eip155: { + accounts: [eipAccount], + chains: eipChains + } + }, + pairingTopic: proposalId, + topic: dappUrl + }; + } + + const eipAccount = account ? `eip155:${account}` : "" + const eipChains = chains ? chains.map((chain) => `eip155:${chain}`) : [] + + return sessionTemplate(dappUrl, dappName, dappIcon, proposalId, eipAccount, eipChains) + } + + TestCase { + id: bcDAppsProviderTest + + property BCDappsProvider componentUnderTest: null + function init() { + componentUnderTest = createTemporaryObject(bcDAppsProviderComponent, root) + } + + function test_addRemoveSession() { + const newSession = buildSession("https://example.com", "Example", "https://example.com/icon.png", "123", "0x123", ["1"]) + componentUnderTest.bcSDK.approveSessionResult("requestID", newSession, null) + + compare(componentUnderTest.connectedSpy.count, 1, "Connected signal should be emitted once") + compare(componentUnderTest.connectedSpy.signalArguments[0][0], "requestID", "Connected signal should have correct proposalId") + compare(componentUnderTest.connectedSpy.signalArguments[0][1], "https://example.com", "Connected signal should have correct topic") + compare(componentUnderTest.connectedSpy.signalArguments[0][2], "https://example.com", "Connected signal should have correct dAppUrl") + + const dapp = componentUnderTest.getByTopic("https://example.com") + verify(!!dapp, "DApp should be found") + compare(dapp.name, "Example", "DApp should have correct name") + compare(dapp.url, "https://example.com", "DApp should have correct url") + compare(dapp.iconUrl, "https://example.com/icon.png", "DApp should have correct iconUrl") + compare(dapp.topic, "https://example.com", "DApp should have correct topic") + compare(dapp.connectorId, componentUnderTest.connectorId, "DApp should have correct connectorId") + compare(dapp.accountAddresses.count, 1, "DApp should have correct accountAddresses count") + compare(dapp.accountAddresses.get(0).address, "0x123", "DApp should have correct accountAddresses address") + compare(dapp.rawSessions.count, 1, "DApp should have correct rawSessions count") + + componentUnderTest.bcSDK.sessionDelete("https://example.com", "") + compare(componentUnderTest.disconnectedSpy.count, 1, "Disconnected signal should be emitted once") + compare(componentUnderTest.disconnectedSpy.signalArguments[0][0], "https://example.com", "Disconnected signal should have correct topic") + compare(componentUnderTest.disconnectedSpy.signalArguments[0][1], "https://example.com", "Disconnected signal should have correct dAppUrl") + } + + function test_disabledSDK() { + componentUnderTest.bcSDK.enabled = false + componentUnderTest.bcSDK.approveSessionResult("requestID", buildSession("https://example.com", "Example", "https://example.com/icon.png", "123", "0x123", ["1"]), "") + compare(componentUnderTest.connectedSpy.count, 0, "Connected signal should not be emitted") + componentUnderTest.bcSDK.sessionDelete("https://example.com", "") + compare(componentUnderTest.disconnectedSpy.count, 0, "Disconnected signal should not be emitted") + } + } +} \ No newline at end of file diff --git a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml index 6ddf86ed70..d42c72869b 100644 --- a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml +++ b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml @@ -25,19 +25,31 @@ Item { width: 600 height: 400 + function mockActiveSession(accountsModel, networksModel, sdk, topic) { + const account = accountsModel.get(1) + const networks = ModelUtils.modelToFlatArray(networksModel, "chainId") + const requestId = 1717149885151715 + const session = JSON.parse(Testing.formatApproveSessionResponse(networks, [account.address])) + const sessionProposal = JSON.parse(Testing.formatSessionProposal()) + + sdk.sessionProposal(sessionProposal) + // Expect to have calls to getActiveSessions from service initialization + const prevRequests = sdk.getActiveSessionsCallbacks.length + sdk.approveSessionResult(sessionProposal.id, session, null) + // Service might trigger a sessionRequest event following the getActiveSessions call + const callback = sdk.getActiveSessionsCallbacks[prevRequests].callback + callback({"b536a": session}) + + return session + } + function mockSessionRequestEvent(tc, sdk, accountsModel, networksModel) { const account = accountsModel.get(1) const network = networksModel.get(1) const topic = "b536a" const requestId = 1717149885151715 + const session = mockActiveSession(accountsModel, networksModel, sdk, topic) const request = buildSessionRequestResolved(tc, account.address, network.chainId, topic, requestId) - // Expect to have calls to getActiveSessions from service initialization - const prevRequests = sdk.getActiveSessionsCallbacks.length - sdk.sessionRequestEvent(request.event) - // Service might trigger a sessionRequest event following the getActiveSessions call - const callback = sdk.getActiveSessionsCallbacks[prevRequests].callback - const session = JSON.parse(Testing.formatApproveSessionResponse([network.chainId, 7], [account.address])) - callback({"b536a": session}) return {sdk, session, account, network, topic, request} } @@ -58,10 +70,12 @@ Item { data: "hello world", preparedData: "hello world", expirationTimestamp: (Date.now() + 10000) / 1000, - sourceId: Constants.DAppConnectors.WalletConnect + sourceId: Constants.DAppConnectors.WalletConnect, + dappName: "Test DApp", + dappUrl: "https://test.dapp", + dappIcon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB0AAAAcCAYAAACdz7SqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAM2SURBVHgBtVbNbtNAEJ7ZpBQ4pRGF9kQqWqkBRNwnwLlxI9y4NX2CiiOntE9QeINw49a8QdwT3NhKQCKaSj4WUVXmABRqe5hxE+PGTuyk5ZOSXe/ftzs/3y5CBiw/NEzw/cdAaCJAifgXdCA4QGAjggbEvbMf0LJt7aSth6lkHjW4akIG8GI2/1k5H7e7XW2PGRdHqWQU8jdoNytZIrnC7YNPupnUnxtuWF01SjhD77hqwPQosNlrxdt34OTb172xpELoKvrA1QW4EqCZRJyLEnpI7ZBQggThlGvXYVLI3HAeE88vfj85Pno/6FaDiqeoEUZlMA9bvc/7cxyxVa6/SeM5j2Tcdn/hnHsNly520s7KAyN0V17+7pWNGhHVhxYJTNLraosLi8e0kMBxT0FH00IW830oeT/ButBertjRQ5BPO1xUQ1IE2oQUHHZ0K6mdI1RzoSEdpqRg76O2lPgSElKDdz919JYMoxA95QDow7qUykWoxTo5z2YIXsGUsLV2CPD1cDu7MODiQKKnsVmI1jhFyQJvFrb6URxFQWJAYYIZSEF6tKZATitFQpehEm1PkCraWYCE+8Nt5ENBwX8EAd2NNaKQxu0ukVuCqwATQHwnjhphShMuiSAVKZ527E6bzYt78Q3SulxvcAm44K8ntXMqagmkJDUpzNwMZGsqBDqLuDXcLvkvqajcWWgm+ZUI6svlym5fsbITlh9tsgi0Ezs5//vkMtBocqSJOZw84ZrHPiXFJ6UwECx5A/FbqNXX2hAiefkzqCNRha1Wi8yJgddeCk4qHzkK1aMgdypfshYRbkTGm3z0Rs6LW0REgDXVEMuMI0TE5kDlgkv8+PjIKRYXfzPxEyH2EYzDzv7L4q1FHsvpg8Gkt186OlGp5uYXZMjzkYS8txwfQnj63//APmzDIF1yWJVrCDJgeZVfjTjCj0KicC3qlny0053FZ/k/PFnyy6P2yv1Kk1T/1eCGF/pEYCncGI6DCzIo/uGnRvg8CfzE5MEPoQGT4Pz5Uj3oxp+hMe0V4oOOrssOMfmWyMJo5X1cG2WZkYIvO2Tn85sGXwg5B5Q9kiKMas5DntPr6Oq4+/gvs8hkkbAzoC8AAAAASUVORK5CYII=" }) - requestItem.resolveDappInfoFromSession({peer: {metadata: {name: "Test DApp", url: "https://test.dapp", icons: ["data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB0AAAAcCAYAAACdz7SqAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAM2SURBVHgBtVbNbtNAEJ7ZpBQ4pRGF9kQqWqkBRNwnwLlxI9y4NX2CiiOntE9QeINw49a8QdwT3NhKQCKaSj4WUVXmABRqe5hxE+PGTuyk5ZOSXe/ftzs/3y5CBiw/NEzw/cdAaCJAifgXdCA4QGAjggbEvbMf0LJt7aSth6lkHjW4akIG8GI2/1k5H7e7XW2PGRdHqWQU8jdoNytZIrnC7YNPupnUnxtuWF01SjhD77hqwPQosNlrxdt34OTb172xpELoKvrA1QW4EqCZRJyLEnpI7ZBQggThlGvXYVLI3HAeE88vfj85Pno/6FaDiqeoEUZlMA9bvc/7cxyxVa6/SeM5j2Tcdn/hnHsNly520s7KAyN0V17+7pWNGhHVhxYJTNLraosLi8e0kMBxT0FH00IW830oeT/ButBertjRQ5BPO1xUQ1IE2oQUHHZ0K6mdI1RzoSEdpqRg76O2lPgSElKDdz919JYMoxA95QDow7qUykWoxTo5z2YIXsGUsLV2CPD1cDu7MODiQKKnsVmI1jhFyQJvFrb6URxFQWJAYYIZSEF6tKZATitFQpehEm1PkCraWYCE+8Nt5ENBwX8EAd2NNaKQxu0ukVuCqwATQHwnjhphShMuiSAVKZ527E6bzYt78Q3SulxvcAm44K8ntXMqagmkJDUpzNwMZGsqBDqLuDXcLvkvqajcWWgm+ZUI6svlym5fsbITlh9tsgi0Ezs5//vkMtBocqSJOZw84ZrHPiXFJ6UwECx5A/FbqNXX2hAiefkzqCNRha1Wi8yJgddeCk4qHzkK1aMgdypfshYRbkTGm3z0Rs6LW0REgDXVEMuMI0TE5kDlgkv8+PjIKRYXfzPxEyH2EYzDzv7L4q1FHsvpg8Gkt186OlGp5uYXZMjzkYS8txwfQnj63//APmzDIF1yWJVrCDJgeZVfjTjCj0KicC3qlny0053FZ/k/PFnyy6P2yv1Kk1T/1eCGF/pEYCncGI6DCzIo/uGnRvg8CfzE5MEPoQGT4Pz5Uj3oxp+hMe0V4oOOrssOMfmWyMJo5X1cG2WZkYIvO2Tn85sGXwg5B5Q9kiKMas5DntPr6Oq4+/gvs8hkkbAzoC8AAAAASUVORK5CYII="]}}}) return requestItem } @@ -70,6 +84,7 @@ Item { WalletConnectSDKBase { property bool sdkReady: true + enabled: true property var getActiveSessionsCallbacks: [] getActiveSessions: function(callback) { @@ -106,7 +121,7 @@ Item { Component { id: serviceComponent - WalletConnectService { + DAppsService { property var onApproveSessionResultTriggers: [] onApproveSessionResult: function(session, error) { onApproveSessionResultTriggers.push({session, error}) @@ -121,7 +136,6 @@ Item { onPairingValidated: function(validationState) { onPairingValidatedTriggers.push({validationState}) } - blockchainNetworksDown: [] } } @@ -243,7 +257,7 @@ Item { } // Used by tst_balanceCheck ListElement { - chainId: 421613 + chainId: 421614 layer: 2 isOnline: true } @@ -294,37 +308,34 @@ Item { } Component { - id: dappsRequestHandlerComponent + id: dappsModuleComponent - DAppsRequestHandler { + DAppsModule { currenciesStore: CurrenciesStore {} - assetsStore: assetsStoreMock + groupedAccountAssetsModel: assetsStoreMock.groupedAccountAssetsModel } } TestCase { - id: requestHandlerTest - name: "DAppsRequestHandler" + id: dappsModuleTest + name: "DAppsModuleTest" // Ensure mocked GroupedAccountsAssetsModel is properly initialized when: windowShown - property DAppsRequestHandler handler: null - - SignalSpy { - id: displayToastMessageSpy - target: requestHandlerTest.handler - signalName: "onDisplayToastMessage" - } + property DAppsModule handler: null function init() { let walletStore = createTemporaryObject(walletStoreComponent, root) verify(!!walletStore) - let sdk = createTemporaryObject(sdkComponent, root, { projectId: "12ab" }) + let sdk = createTemporaryObject(sdkComponent, root, { projectId: "12ab", enabled: true }) verify(!!sdk) + let bcSdk = createTemporaryObject(sdkComponent, root, { projectId: "12ab", enabled: false }) + verify(!!bcSdk) let store = createTemporaryObject(dappsStoreComponent, root) verify(!!store) - handler = createTemporaryObject(dappsRequestHandlerComponent, root, { - sdk: sdk, + handler = createTemporaryObject(dappsModuleComponent, root, { + wcSdk: sdk, + bcSdk: bcSdk, store: store, accountsModel: walletStore.nonWatchAccounts, networksModel: walletStore.filteredFlatModelWithOnlineStat @@ -333,13 +344,9 @@ Item { sdk.getActiveSessionsCallbacks = [] } - function cleanup() { - displayToastMessageSpy.clear() - } - function test_TestAuthentication() { - let td = mockSessionRequestEvent(this, handler.sdk, handler.accountsModel, handler.networksModel) - handler.sdk.sessionRequestEvent(td.request.event) + let td = mockSessionRequestEvent(this, handler.wcSdk, handler.accountsModel, handler.networksModel) + handler.wcSdk.sessionRequestEvent(td.request.event) let request = handler.requestsModel.findById(td.request.requestId) request.accept() compare(handler.store.authenticateUserCalls.length, 1, "expected a call to store.authenticateUser") @@ -351,7 +358,7 @@ Item { } function test_onSessionRequestEventDifferentCaseForAddress() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 @@ -362,6 +369,7 @@ Item { const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) // Expect to have calls to getActiveSessions from service initialization const prevRequests = sdk.getActiveSessionsCallbacks.length + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) compare(sdk.getActiveSessionsCallbacks.length, 1, "expected DAppsRequestHandler call sdk.getActiveSessions") @@ -369,7 +377,7 @@ Item { // Tests that the request is ignored if not in the current profile (don't have the PK for the address) function test_onSessionRequestEventMissingAddress() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0xY" const chainId = 2 @@ -378,12 +386,13 @@ Item { const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddressUpper}"`] const topic = "b536a" const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) - // Expect to have calls to getActiveSessions from service initialization - const prevRequests = sdk.getActiveSessionsCallbacks.length + + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) + sdk.getActiveSessionsCallbacks = [] sdk.sessionRequestEvent(session) compare(sdk.getActiveSessionsCallbacks.length, 0, "expected DAppsRequestHandler don't call sdk.getActiveSessions") - compare(sdk.rejectSessionRequestCalls.length, 0, "expected no call to service.rejectSessionRequest") + compare(sdk.rejectSessionRequestCalls.length, 1, "expected to reject the request") } function test_balanceCheck_data() { @@ -434,7 +443,7 @@ Item { } function test_balanceCheck(data) { - let sdk = handler.sdk + let sdk = handler.wcSdk // Override the suggestedFees if (!!data.maxFeePerGasM) { @@ -456,6 +465,7 @@ Item { }`] let topic = "b536a" let session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) compare(sdk.getActiveSessionsCallbacks.length, 1, "expected DAppsRequestHandler call sdk.getActiveSessions") @@ -473,7 +483,7 @@ Item { } function test_sessionRequestExpiryInTheFuture() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 const method = "personal_sign" @@ -486,6 +496,7 @@ Item { // Expect to have calls to getActiveSessions from service initialization const prevRequests = sdk.getActiveSessionsCallbacks.length + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) verify(handler.requestsModel.count === 1, "expected a request to be added") @@ -496,7 +507,7 @@ Item { function test_sessionRequestExpiryInThePast() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 const method = "personal_sign" @@ -508,18 +519,18 @@ Item { verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past") + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) verify(handler.requestsModel.count === 1, "expected a request to be added") const request = handler.requestsModel.findRequest(topic, session.id) verify(!!request, "expected request to be found") verify(request.isExpired(), "expected request to be expired") - verify(displayToastMessageSpy.count === 0, "no toast message should be displayed") } function test_wcSignalsSessionRequestExpiry() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 const method = "personal_sign" @@ -529,6 +540,8 @@ Item { const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) verify(session.params.request.expiryTimestamp > Date.now() / 1000, "expected expiryTimestamp to be in the future") + + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) const request = handler.requestsModel.findRequest(topic, session.id) verify(!!request, "expected request to be found") @@ -536,12 +549,11 @@ Item { sdk.sessionRequestExpired(session.id) verify(request.isExpired(), "expected request to be expired") - verify(displayToastMessageSpy.count === 0, "no toast message should be displayed") } function test_acceptExpiredSessionRequest() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 const method = "personal_sign" @@ -552,12 +564,12 @@ Item { session.params.request.expiryTimestamp = (Date.now() - 10000) / 1000 verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past") - + + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) verify(handler.requestsModel.count === 1, "expected a request to be added") const request = handler.requestsModel.findRequest(topic, session.id) - request.resolveDappInfoFromSession({peer: {metadata: {name: "Test DApp", url: "https://test.dapp", icons:[]}}}) verify(!!request, "expected request to be found") verify(request.isExpired(), "expected request to be expired") verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest") @@ -567,13 +579,11 @@ Item { handler.store.userAuthenticated(topic, session.id, "1234", "", message) verify(sdk.rejectSessionRequestCalls.length === 1, "expected a call to sdk.rejectSessionRequest") sdk.sessionRequestUserAnswerResult(topic, session.id, false, "") - verify(displayToastMessageSpy.count === 1, "expected a toast message to be displayed") - compare(displayToastMessageSpy.signalArguments[0][0], "test.dapp sign request timed out") } function test_rejectExpiredSessionRequest() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 const method = "personal_sign" @@ -585,6 +595,7 @@ Item { verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past") + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest") @@ -597,7 +608,7 @@ Item { function test_signFailedAuthOnExpiredRequest() { - const sdk = handler.sdk + const sdk = handler.wcSdk const testAddressUpper = "0x3A" const chainId = 2 const method = "personal_sign" @@ -609,6 +620,7 @@ Item { verify(session.params.request.expiryTimestamp < Date.now() / 1000, "expected expiryTimestamp to be in the past") + mockActiveSession(handler.accountsModel, handler.networksModel, sdk, topic) sdk.sessionRequestEvent(session) verify(sdk.rejectSessionRequestCalls.length === 0, "expected no call to sdk.rejectSessionRequest") @@ -621,14 +633,14 @@ Item { } TestCase { - id: walletConnectServiceTest - name: "WalletConnectService" + id: dappsServiceTest + name: "DAppsService" - property WalletConnectService service: null + property DAppsService service: null SignalSpy { id: connectDAppSpy - target: walletConnectServiceTest.service + target: dappsServiceTest.service signalName: "connectDApp" property var argPos: { @@ -640,35 +652,52 @@ Item { } } - readonly property SignalSpy sessionRequestSpy: SignalSpy { - target: walletConnectServiceTest.service - signalName: "sessionRequest" - - property var argPos: { - "id": 0 + function populateDAppData(topic) { + const dapp = { + topic, + name: Testing.dappName, + url: Testing.dappUrl, + iconUrl: Testing.dappFirstIcon, + connectorId: 0, + accountAddresses: [{address: "0x123"}], + rawSessions: [{session: {topic}}] } + findChild(service.dappsModule, "DAppsModel").model.append(dapp) } function init() { let walletStore = createTemporaryObject(walletStoreComponent, root) verify(!!walletStore) - let sdk = createTemporaryObject(sdkComponent, root, { projectId: "12ab" }) + let sdk = createTemporaryObject(sdkComponent, root, { projectId: "12ab", enabled: true }) verify(!!sdk) + let bcSdk = createTemporaryObject(sdkComponent, root, { projectId: "12ab", enabled: false }) let store = createTemporaryObject(dappsStoreComponent, root) verify(!!store) - service = createTemporaryObject(serviceComponent, root, {wcSDK: sdk, store: store, walletRootStore: walletStore}) + let dappsModuleObj = createTemporaryObject(dappsModuleComponent, root, { + wcSdk: sdk, + bcSdk: bcSdk, + store: store, + accountsModel: walletStore.nonWatchAccounts, + networksModel: walletStore.filteredFlatModelWithOnlineStat + }) + + service = createTemporaryObject(serviceComponent, root, { + dappsModule: dappsModuleObj, + selectedAddress: "", + accountsModel: walletStore.nonWatchAccounts + }) verify(!!service) } function cleanup() { connectDAppSpy.clear() - sessionRequestSpy.clear() } function testSetupPair(sessionProposalPayload) { - let sdk = service.wcSDK - let walletStore = service.walletRootStore - let store = service.store + let sdk = service.dappsModule.wcSdk + let accountsModel = service.dappsModule.accountsModel + let networksModel = service.dappsModule.networksModel + let store = service.dappsModule.store service.pair("wc:12ab@1?bridge=https%3A%2F%2Fbridge.walletconnect.org&key=12ab") compare(sdk.pairCalled, 1, "expected a call to sdk.pair") @@ -680,21 +709,21 @@ Item { // All calls to SDK are expected as events to be made by the wallet connect SDK let chainsForApproval = args.supportedNamespaces.eip155.chains - let networksArray = ModelUtils.modelToArray(walletStore.filteredFlatModel).map(entry => entry.chainId) + let networksArray = ModelUtils.modelToArray(networksModel).map(entry => entry.chainId) verify(networksArray.every(chainId => chainsForApproval.some(eip155Chain => eip155Chain === `eip155:${chainId}`)), "expect all the networks to be present") // We test here all accounts for one chain only, we have separate tests to validate that all accounts are present let allAccountsForApproval = args.supportedNamespaces.eip155.accounts - let accountsArray = ModelUtils.modelToArray(walletStore.nonWatchAccounts).map(entry => entry.address) + let accountsArray = ModelUtils.modelToArray(accountsModel).map(entry => entry.address) verify(accountsArray.every(address => allAccountsForApproval.some(eip155Address => eip155Address === `eip155:${networksArray[0]}:${address}`)), "expect at least all accounts for the first chain to be present" ) - return {sdk, walletStore, store, networksArray, accountsArray} + return {sdk, store, networksArray, accountsArray, networksModel, accountsModel} } function test_TestPairing() { - const {sdk, walletStore, store, networksArray, accountsArray} = testSetupPair(Testing.formatSessionProposal()) + const {sdk, store, networksArray, accountsArray, networksModel, accountsModel} = testSetupPair(Testing.formatSessionProposal()) compare(sdk.buildApprovedNamespacesCalls.length, 1, "expected a call to sdk.buildApprovedNamespaces") let allApprovedNamespaces = JSON.parse(Testing.formatBuildApprovedNamespacesResult(networksArray, accountsArray)) @@ -708,7 +737,7 @@ Item { compare(connectArgs[connectDAppSpy.argPos.dappIcon], Testing.dappFirstIcon, "expected dappIcon to be set") verify(!!connectArgs[connectDAppSpy.argPos.key], "expected key to be set") - let selectedAccount = walletStore.nonWatchAccounts.get(1).address + let selectedAccount = accountsModel.get(1).address service.approvePairSession(connectArgs[connectDAppSpy.argPos.key], connectArgs[connectDAppSpy.argPos.dappChains], selectedAccount) compare(sdk.buildApprovedNamespacesCalls.length, 2, "expected a call to sdk.buildApprovedNamespaces") const approvedArgs = sdk.buildApprovedNamespacesCalls[1] @@ -740,7 +769,7 @@ Item { } function test_TestPairingUnsupportedNetworks() { - const {sdk, walletStore, store} = testSetupPair(Testing.formatSessionProposal()) + const {sdk, store} = testSetupPair(Testing.formatSessionProposal()) const approvedArgs = sdk.buildApprovedNamespacesCalls[0] sdk.buildApprovedNamespacesResult(approvedArgs.id, {}, "Non conforming namespaces. approve() namespaces chains don't satisfy required namespaces") @@ -751,7 +780,7 @@ Item { function test_SessionRequestMainFlow() { // All calls to SDK are expected as events to be made by the wallet connect SDK - const sdk = service.wcSDK + const sdk = service.dappsModule.wcSdk const walletStore = service.walletRootStore const store = service.store @@ -762,17 +791,11 @@ Item { const params = [`"${DAppsHelpers.strToHex(message)}"`, `"${testAddress}"`] const topic = "b536a" const session = JSON.parse(Testing.formatSessionRequest(chainId, method, params, topic)) - // Expect to have calls to getActiveSessions from service initialization - const prevRequests = sdk.getActiveSessionsCallbacks.length + + populateDAppData(topic) sdk.sessionRequestEvent(session) - compare(sdk.getActiveSessionsCallbacks.length, prevRequests + 1, "expected DAppsRequestHandler call sdk.getActiveSessions") - const callback = sdk.getActiveSessionsCallbacks[prevRequests].callback - callback({"b536a": JSON.parse(Testing.formatApproveSessionResponse([chainId, 7], [testAddress]))}) - - compare(sessionRequestSpy.count, 1, "expected service.sessionRequest trigger") - const requestId = sessionRequestSpy.signalArguments[0][sessionRequestSpy.argPos.id] - const request = service.sessionRequestsModel.findById(requestId) + const request = service.sessionRequestsModel.findById(session.id) verify(!!request, "expected request to be found") compare(request.topic, topic, "expected topic to be set") compare(request.method, method, "expected method to be set") @@ -795,64 +818,6 @@ Item { // } } - Component { - id: dappsListProviderComponent - DAppsListProvider { - } - } - - TestCase { - name: "DAppsListProvider" - - property DAppsListProvider provider: null - - readonly property var dappsListReceivedJsonStr: '[{"url":"https://tst1.com","name":"name1","iconUrl":"https://tst1.com/u/1"},{"url":"https://tst2.com","name":"name2","iconUrl":"https://tst2.com/u/2"}]' - - function init() { - // Simulate the SDK not being ready - let sdk = createTemporaryObject(sdkComponent, root, {projectId: "12ab", sdkReady: false}) - verify(!!sdk) - let store = createTemporaryObject(dappsStoreComponent, root, { - dappsListReceivedJsonStr: dappsListReceivedJsonStr - }) - verify(!!store) - const walletStore = createTemporaryObject(walletStoreComponent, root) - verify(!!walletStore) - provider = createTemporaryObject(dappsListProviderComponent, root, {sdk: sdk, store: store, supportedAccountsModel: walletStore.nonWatchAccounts}) - verify(!!provider) - sdk.getActiveSessionsCallbacks = [] - } - - function cleanup() { - } - - // Implemented as a regression to metamask not having icons which failed dapps list - function test_TestUpdateDapps() { - // Validate that persistance fallback is working - compare(provider.dappsModel.count, 2, "expected dappsModel have the right number of elements") - let persistanceList = JSON.parse(dappsListReceivedJsonStr) - compare(provider.dappsModel.get(0).url, persistanceList[0].url, "expected url to be set") - compare(provider.dappsModel.get(0).iconUrl, persistanceList[0].iconUrl, "expected iconUrl to be set") - compare(provider.dappsModel.get(1).name, persistanceList[1].name, "expected name to be set") - - // Validate that SDK's `getActiveSessions` is not called if not ready - let sdk = provider.sdk - compare(sdk.getActiveSessionsCallbacks.length, 0, "expected no calls to sdk.getActiveSessions yet") - sdk.sdkReady = true - compare(sdk.getActiveSessionsCallbacks.length, 1, "expected a call to sdk.getActiveSessions when SDK becomes ready") - let callback = sdk.getActiveSessionsCallbacks[0].callback - const address = ModelUtils.get(provider.supportedAccountsModel, 0, "address") - let session = JSON.parse(Testing.formatApproveSessionResponse([1, 2], [address], {dappMetadataJsonString: Testing.noIconsDappMetadataJsonString})) - callback({"b536a": session, "b537b": session}) - compare(provider.dappsModel.count, 1, "expected dappsModel have the SDK's reported dapp, 2 sessions of the same dApp per 2 wallet account, meaning 1 dApp model entry") - compare(provider.dappsModel.get(0).iconUrl, "", "expected iconUrl to be missing") - let updateCalls = provider.store.updateWalletConnectSessionsCalls - compare(updateCalls.length, 1, "expected a call to store.updateWalletConnectSessions") - verify(updateCalls[0].activeTopicsJson.includes("b536a")) - verify(updateCalls[0].activeTopicsJson.includes("b537b")) - } - } - TestCase { name: "ServiceHelpers" diff --git a/storybook/qmlTests/tests/tst_SessionRequest.qml b/storybook/qmlTests/tests/tst_SessionRequest.qml new file mode 100644 index 0000000000..7d0be2d0e7 --- /dev/null +++ b/storybook/qmlTests/tests/tst_SessionRequest.qml @@ -0,0 +1,72 @@ +import QtQuick 2.15 +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps.types 1.0 + +Item { + id: root + + TestCase { + id: sessionRequestTest + function test_parse_data() { + return [ + { + // valid eth_sign + expected: {"request":{"event":{"id":1730896224189361,"params":{"chainId":"eip155:1","request":{"method":"eth_sign","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","requestId":1730896224189361,"method":"eth_sign","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"message":"\u0005=H\u0000Æ5NvÇ])\"È/µ³ôW{/àTòà"},"preparedData":"\u0005=H\u0000Æ5NvÇ])\"È/µ³ôW{/àTòà","value":"0","chainId":1},"error":0}, + event: {"id":1730896224189361,"params":{"chainId":"eip155:1","request":{"method":"eth_sign","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + }, + { + // valid personal_sign + expected: {"request":{"event":{"id":1730896110928724,"params":{"chainId":"eip155:1","request":{"method":"personal_sign","params":["0x4578616d706c652060706572736f6e616c5f7369676e60206d657373616765","0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","Example password"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","requestId":1730896110928724,"method":"personal_sign","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"message":"Example `personal_sign` message"},"preparedData":"Example `personal_sign` message","value":"0","chainId":1},"error":0}, + event: {"id":1730896110928724,"params":{"chainId":"eip155:1","request":{"method":"personal_sign","params":["0x4578616d706c652060706572736f6e616c5f7369676e60206d657373616765","0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","Example password"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + }, + { + // valid typed_data + expected: {"request":{"event":{"id":1730896224189361,"params":{"chainId":"eip155:1","request":{"method":"eth_sign","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","requestId":1730896224189361,"method":"eth_sign","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"message":`��\u0005=H\u0000�5NvǘZ�])\"�/���W{/����T��`},"preparedData":"��\u0005=H\u0000�5NvǘZ�])\"�/���W{/����T��","value":"0","chainId":1}, "error":0}, + event: {"id":1730896224189361,"params":{"chainId":"eip155:1","request":{"method":"eth_sign","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","0x879a053d4800c6354e76c7985a865d2922c82fb5b3f4577b2fe08b998954f2e0"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + }, + { + // valid eth_signTypedData_v4 + expected: {"request":{"event":{"id":1730896495619543,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","requestId":1730896495619543,"method":"eth_signTypedData_v4","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"message":"{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"},"preparedData":"{\n \"domain\": {\n \"chainId\": \"1\",\n \"name\": \"Ether Mail\",\n \"verifyingContract\": \"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\n \"version\": \"1\"\n },\n \"message\": {\n \"contents\": \"Hello, Bob!\",\n \"from\": {\n \"name\": \"Cow\",\n \"wallets\": [\n \"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\n \"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"\n ]\n },\n \"to\": [\n {\n \"name\": \"Bob\",\n \"wallets\": [\n \"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\n \"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\n \"0xB0B0b0b0b0b0B000000000000000000000000000\"\n ]\n }\n ],\n \"attachment\": \"0x\"\n },\n \"primaryType\": \"Mail\",\n \"types\": {\n \"EIP712Domain\": [\n {\n \"name\": \"name\",\n \"type\": \"string\"\n },\n {\n \"name\": \"version\",\n \"type\": \"string\"\n },\n {\n \"name\": \"chainId\",\n \"type\": \"uint256\"\n },\n {\n \"name\": \"verifyingContract\",\n \"type\": \"address\"\n }\n ],\n \"Group\": [\n {\n \"name\": \"name\",\n \"type\": \"string\"\n },\n {\n \"name\": \"members\",\n \"type\": \"Person[]\"\n }\n ],\n \"Mail\": [\n {\n \"name\": \"from\",\n \"type\": \"Person\"\n },\n {\n \"name\": \"to\",\n \"type\": \"Person[]\"\n },\n {\n \"name\": \"contents\",\n \"type\": \"string\"\n },\n {\n \"name\": \"attachment\",\n \"type\": \"bytes\"\n }\n ],\n \"Person\": [\n {\n \"name\": \"name\",\n \"type\": \"string\"\n },\n {\n \"name\": \"wallets\",\n \"type\": \"address[]\"\n }\n ]\n }\n}","value":"0","chainId":1},"error":0}, + event: {"id":1730896495619543,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + }, + { + // valid sign permit + expected: {"request":{"event":{"id":1730896621650994,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","{\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Permit\":[{\"name\":\"owner\",\"type\":\"address\"},{\"name\":\"spender\",\"type\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\"}]},\"primaryType\":\"Permit\",\"domain\":{\"name\":\"MyToken\",\"version\":\"1\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"chainId\":null},\"message\":{\"owner\":\"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\",\"spender\":\"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4\",\"value\":3000,\"nonce\":0,\"deadline\":50000000000}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","requestId":1730896621650994,"method":"eth_signTypedData_v4","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"message":"{\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Permit\":[{\"name\":\"owner\",\"type\":\"address\"},{\"name\":\"spender\",\"type\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\"}]},\"primaryType\":\"Permit\",\"domain\":{\"name\":\"MyToken\",\"version\":\"1\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"chainId\":null},\"message\":{\"owner\":\"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\",\"spender\":\"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4\",\"value\":3000,\"nonce\":0,\"deadline\":50000000000}}"},"preparedData":"{\n \"types\": {\n \"EIP712Domain\": [\n {\n \"name\": \"name\",\n \"type\": \"string\"\n },\n {\n \"name\": \"version\",\n \"type\": \"string\"\n },\n {\n \"name\": \"chainId\",\n \"type\": \"uint256\"\n },\n {\n \"name\": \"verifyingContract\",\n \"type\": \"address\"\n }\n ],\n \"Permit\": [\n {\n \"name\": \"owner\",\n \"type\": \"address\"\n },\n {\n \"name\": \"spender\",\n \"type\": \"address\"\n },\n {\n \"name\": \"value\",\n \"type\": \"uint256\"\n },\n {\n \"name\": \"nonce\",\n \"type\": \"uint256\"\n },\n {\n \"name\": \"deadline\",\n \"type\": \"uint256\"\n }\n ]\n },\n \"primaryType\": \"Permit\",\n \"domain\": {\n \"name\": \"MyToken\",\n \"version\": \"1\",\n \"verifyingContract\": \"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\n \"chainId\": null\n },\n \"message\": {\n \"owner\": \"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\",\n \"spender\": \"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4\",\n \"value\": 3000,\n \"nonce\": 0,\n \"deadline\": 50000000000\n }\n}","value":"0","chainId":1},"error":0}, + event: {"id":1730896621650994,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","{\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Permit\":[{\"name\":\"owner\",\"type\":\"address\"},{\"name\":\"spender\",\"type\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\"},{\"name\":\"nonce\",\"type\":\"uint256\"},{\"name\":\"deadline\",\"type\":\"uint256\"}]},\"primaryType\":\"Permit\",\"domain\":{\"name\":\"MyToken\",\"version\":\"1\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"chainId\":null},\"message\":{\"owner\":\"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\",\"spender\":\"0x5B38Da6a701c568545dCfcB03FcB875f56beddC4\",\"value\":3000,\"nonce\":0,\"deadline\":50000000000}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + }, + { + // valid SIWE + expected: {"request":{"event":{"id":1730896696709302,"params":{"chainId":"eip155:1","request":{"method":"personal_sign","params":["0x6d6574616d61736b2e6769746875622e696f2077616e747320796f7520746f207369676e20696e207769746820796f757220457468657265756d206163636f756e743a0a3078374634374332653138613442426635343837453666623038326543324439416230453664373234300a0a492061636365707420746865204d6574614d61736b205465726d73206f6620536572766963653a2068747470733a2f2f636f6d6d756e6974792e6d6574616d61736b2e696f2f746f730a0a5552493a2068747470733a2f2f6d6574616d61736b2e6769746875622e696f0a56657273696f6e3a20310a436861696e2049443a20310a4e6f6e63653a2033323839313735370a4973737565642041743a20323032312d30392d33305431363a32353a32342e3030305a","0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","Example password"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","requestId":1730896696709302,"method":"personal_sign","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"message":"metamask.github.io wants you to sign in with your Ethereum account:\n0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://metamask.github.io\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z"},"preparedData":"metamask.github.io wants you to sign in with your Ethereum account:\n0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://metamask.github.io\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z","value":"0","chainId":1},"error":0}, + event: {"id":1730896696709302,"params":{"chainId":"eip155:1","request":{"method":"personal_sign","params":["0x6d6574616d61736b2e6769746875622e696f2077616e747320796f7520746f207369676e20696e207769746820796f757220457468657265756d206163636f756e743a0a3078374634374332653138613442426635343837453666623038326543324439416230453664373234300a0a492061636365707420746865204d6574614d61736b205465726d73206f6620536572766963653a2068747470733a2f2f636f6d6d756e6974792e6d6574616d61736b2e696f2f746f730a0a5552493a2068747470733a2f2f6d6574616d61736b2e6769746875622e696f0a56657273696f6e3a20310a436861696e2049443a20310a4e6f6e63653a2033323839313735370a4973737565642041743a20323032312d30392d33305431363a32353a32342e3030305a","0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","Example password"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + }, + { + // valid EIP 1159 transaction + expected: {"request":{"event":{"id":1730899979094571,"params":{"chainId":"eip155:1","request":{"method":"eth_sendTransaction","params":[{"from":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","gasLimit":"0x5028","maxFeePerGas":"0x2540be400","maxPriorityFeePerGas":"0x3b9aca00","to":"0x0c54FcCd2e384b4BB6f2E405Bf5Cbc15a017AaFb","value":"0x0"}]}},"topic":"147fa9ddffcf9d782b5e002eb36b041e43a1db2bad79a598da6926105ce6680f","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}},"topic":"147fa9ddffcf9d782b5e002eb36b041e43a1db2bad79a598da6926105ce6680f","requestId":1730899979094571,"method":"eth_sendTransaction","account":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","data":{"tx":{"from":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","gasLimit":"0x5028","maxFeePerGas":"0x2540be400","maxPriorityFeePerGas":"0x3b9aca00","to":"0x0c54FcCd2e384b4BB6f2E405Bf5Cbc15a017AaFb","value":"0x0"}},"preparedData":"{\n \"from\": \"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240\",\n \"gasLimit\": 20520,\n \"maxFeePerGas\": \"10\",\n \"maxPriorityFeePerGas\": \"1\",\n \"to\": \"0x0c54FcCd2e384b4BB6f2E405Bf5Cbc15a017AaFb\",\n \"value\": \"0\"\n}","value":"0","transaction":{"from":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","gasLimit":"0x5028","maxFeePerGas":"0x2540be400","maxPriorityFeePerGas":"0x3b9aca00","to":"0x0c54FcCd2e384b4BB6f2E405Bf5Cbc15a017AaFb","value":"0x0"},"chainId":1},"error":0}, + event: {"id":1730899979094571,"params":{"chainId":"eip155:1","request":{"method":"eth_sendTransaction","params":[{"from":"0x7F47C2e18a4BBf5487E6fb082eC2D9Ab0E6d7240","gasLimit":"0x5028","maxFeePerGas":"0x2540be400","maxPriorityFeePerGas":"0x3b9aca00","to":"0x0c54FcCd2e384b4BB6f2E405Bf5Cbc15a017AaFb","value":"0x0"}]}},"topic":"147fa9ddffcf9d782b5e002eb36b041e43a1db2bad79a598da6926105ce6680f","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + } + ] + } + function test_parse(data) { + const { expected, event } = data + const result = SessionRequest.parse(event) + + compare(result.error, expected.error) + compare(result.request.topic, expected.request.topic) + compare(result.request.requestId, expected.request.requestId) + compare(result.request.method, expected.request.method) + compare(result.request.account, expected.request.account) + compare(result.request.value, expected.request.value) + compare(result.request.chainId, expected.request.chainId) + compare(!!result.request.transaction, !!expected.request.transaction) + if (!!result.request.transaction) { + compare(result.request.transaction.from, expected.request.transaction.from) + compare(result.request.transaction.gasLimit, expected.request.transaction.gasLimit) + compare(result.request.transaction.maxFeePerGas, expected.request.transaction.maxFeePerGas) + compare(result.request.transaction.maxPriorityFeePerGas, expected.request.transaction.maxPriorityFeePerGas) + compare(result.request.transaction.to, expected.request.transaction.to) + compare(result.request.transaction.value, expected.request.transaction.value) + } + } + } +} \ No newline at end of file diff --git a/storybook/qmlTests/tests/tst_SessionRequestWithAuth.qml b/storybook/qmlTests/tests/tst_SessionRequestWithAuth.qml index 1bc78cf260..917d5cece2 100644 --- a/storybook/qmlTests/tests/tst_SessionRequestWithAuth.qml +++ b/storybook/qmlTests/tests/tst_SessionRequestWithAuth.qml @@ -47,6 +47,9 @@ Item { sourceId: 0 data: "data" preparedData: "preparedData" + dappUrl: "dappUrl" + dappIcon: "dappIcon" + dappName: "dappName" } } @@ -88,7 +91,6 @@ Item { componentUnderTest.store.userAuthenticationFailed("topic", "id") compare(componentUnderTest.executeSpy.count, 0) - compare(componentUnderTest.rejectedSpy.count, 1) compare(componentUnderTest.authFailedSpy.count, 1) compare(componentUnderTest.store.authenticateUserCalls.length, 1) } diff --git a/storybook/qmlTests/tests/tst_SignRequestPlugin.qml b/storybook/qmlTests/tests/tst_SignRequestPlugin.qml new file mode 100644 index 0000000000..6afa2e4752 --- /dev/null +++ b/storybook/qmlTests/tests/tst_SignRequestPlugin.qml @@ -0,0 +1,268 @@ +import QtQuick 2.15 +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.plugins 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 + +import shared.stores 1.0 +import utils 1.0 + +Item { + id: root + + Component { + id: signRequestPluginComponent + SignRequestPlugin { + id: plugin + property SignalSpy acceptedSpy: SignalSpy { target: plugin; signalName: "accepted" } + property SignalSpy rejectedSpy: SignalSpy { target: plugin; signalName: "rejected" } + property SignalSpy signCompletedSpy: SignalSpy { target: plugin; signalName: "signCompleted" } + + sdk: WalletConnectSDKBase { + id: sdk + enabled: true + projectId: "" + property bool sdkReady: true + + property var getActiveSessionsCallbacks: [] + getActiveSessions: function(callback) { + getActiveSessionsCallbacks.push({callback}) + } + + property var acceptSessionRequestCalls: [] + acceptSessionRequest: function(topic, id, signature) { + acceptSessionRequestCalls.push({topic, id, signature}) + } + + property var rejectSessionRequestCalls: [] + rejectSessionRequest: function(topic, id, error) { + rejectSessionRequestCalls.push({topic, id, error}) + } + } + store: DAppsStore { + id: dappsStore + + signal userAuthenticated(string topic, string id, string password, string pin, string payload) + signal userAuthenticationFailed(string topic, string id) + signal signingResult(string topic, string id, string data) + + function hexToDec(hex) { + return parseInt(hex, 16) + } + + function getEstimatedTime() { + return Constants.TransactionEstimatedTime.LessThanThreeMins + } + + function convertFeesInfoToHex(feesInfo) { + return null + } + + property var mockedSuggestedFees: ({ + gasPrice: 2.0, + baseFee: 5.0, + maxPriorityFeePerGas: 2.0, + maxFeePerGasL: 1.0, + maxFeePerGasM: 1.1, + maxFeePerGasH: 1.2, + l1GasFee: 0.0, + eip1559Enabled: true + }) + function getSuggestedFees() { + return mockedSuggestedFees + } + + property var authenticateUserCalls: [] + function authenticateUser(topic, id, address) { + authenticateUserCalls.push({topic, id, address}) + } + + property var signMessageCalls: [] + function signMessage(topic, id, address, message, password, pin) { + signMessageCalls.push({topic, id, address, password, pin}) + } + + property var signMessageUnsafeCalls: [] + function signMessageUnsafe(topic, id, address, data, password, pin) { + signMessageUnsafeCalls.push({topic, id, address, data, password, pin}) + } + + property var safeSignTypedDataCalls: [] + function safeSignTypedData(topic, id, address, message, chainId, legacy, password, pin) { + safeSignTypedDataCalls.push({topic, id, address, message, chainId, legacy, password, pin}) + } + + property var signTransactionCalls: [] + function signTransaction(topic, id, address, chainId, txObj, password, pin) { + signTransactionCalls.push({topic, id, address, chainId, txObj, password, pin}) + } + + property var sendTransactionCalls: [] + function sendTransaction(topic, id, address, chainID, txObj, password, pin) { + sendTransactionCalls.push({topic, id, address, chainID, txObj, password, pin}) + } + } + dappsModel: ListModel { + id: dappsModel + } + groupedAccountAssetsModel: ListModel { + id: groupedAccountAssetsModel + } + networksModel: ListModel { + id: networksModel + ListElement { + chainId: 1 + layer: 1 + } + } + accountsModel: ListModel { + id: accountsModel + ListElement { + address: "0x123" + } + } + currentCurrency: "USD" + requests: SessionRequestsModel {} + getFiatValue: (balance, cryptoSymbol) => { + return parseFloat(balance) + } + } + } + + TestCase { + id: signRequestPluginTest + + property SignRequestPlugin componentUnderTest: null + + function populateDAppData(topic) { + const dapp = { + topic, + name: "Example", + url: "https://example.com", + iconUrl: "https://example.com/icon.png", + connectorId: 0, + accountAddresses: [{address: "0x123"}], + rawSessions: [{session: {topic}}] + } + componentUnderTest.dappsModel.append(dapp) + } + + function executeRequest(signEvent) { + populateDAppData(signEvent.topic) + componentUnderTest.sdk.sessionRequestEvent(signEvent) + // Execute the request + const request = componentUnderTest.requests.get(0) + request.requestItem.execute("passowrd", "pin") + + componentUnderTest.store.signingResult(signEvent.topic, signEvent.id, "result") + compare(componentUnderTest.sdk.acceptSessionRequestCalls.length, 1, "Accept session request should be called") + compare(componentUnderTest.sdk.acceptSessionRequestCalls[0].signature, "result", "Accept session request should be called with the correct signature") + compare(componentUnderTest.sdk.acceptSessionRequestCalls[0].topic.toString(), signEvent.topic.toString(), "Accept session request should be called with the correct topic") + compare(componentUnderTest.sdk.acceptSessionRequestCalls[0].id.toString(), signEvent.id.toString(), "Accept session request should be called with the correct id") + + compare(componentUnderTest.acceptedSpy.count, 1, "Accepted signal should be emitted") + } + + function init() { + componentUnderTest = createTemporaryObject(signRequestPluginComponent, root) + } + + function test_signMessage() { + const signEvent = {"id":1730896110928724,"params":{"chainId":"eip155:1","request":{"method":"personal_sign","params":["0x4578616d706c652060706572736f6e616c5f7369676e60206d657373616765","0x123","Example password"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + // Case 1: DApp not found + ignoreWarning(`Error finding dapp for topic ${signEvent.topic} id ${signEvent.id}`) + componentUnderTest.sdk.sessionRequestEvent(signEvent) + compare(componentUnderTest.sdk.rejectSessionRequestCalls.length, 1, "Reject session request should not be called") + + // Case 2: DApp found + executeRequest(signEvent) + compare(componentUnderTest.store.signMessageCalls.length, 1, "Sign message should be called") + componentUnderTest.sdk.sessionRequestUserAnswerResult(signEvent.topic, signEvent.id, true, null) + + const requestItem = componentUnderTest.requests.get(0).requestItem + compare(requestItem.requestId, signEvent.id.toString(), "Request id should be set") + compare(requestItem.topic, signEvent.topic.toString(), "Topic should be set") + compare(requestItem.method, signEvent.params.request.method, "Method should be set") + compare(requestItem.accountAddress, signEvent.params.request.params[1], "Account address should be set") + compare(requestItem.chainId, signEvent.params.chainId.split(':').pop().trim(), "Chain id should be set") + compare(requestItem.sourceId, componentUnderTest.dappsModel.get(0).connectorId, "Source id should be set") + + compare(componentUnderTest.signCompletedSpy.count, 1, "Sign completed signal should be emitted") + } + + function test_signMessageUnsafe() { + const signEvent = {"id":1730896224189361,"params":{"chainId":"eip155:1","request":{"method":"eth_sign","params":["0x123","0x123"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + + executeRequest(signEvent) + + compare(componentUnderTest.store.signMessageUnsafeCalls.length, 1, "Sign message unsafe should be called") + componentUnderTest.sdk.sessionRequestUserAnswerResult(signEvent.topic, signEvent.id, true, null) + + compare(componentUnderTest.signCompletedSpy.count, 1, "Sign completed signal should be emitted") + } + + function test_safeSignTypedData() { + const signEvent = {"id":1730896495619543,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x123","{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + + executeRequest(signEvent) + + compare(componentUnderTest.store.safeSignTypedDataCalls.length, 1, "Safe sign typed data should be called") + componentUnderTest.sdk.sessionRequestUserAnswerResult(signEvent.topic, signEvent.id, true, null) + + compare(componentUnderTest.signCompletedSpy.count, 1, "Sign completed signal should be emitted") + } + + function test_sendTransaction() { + const signEvent = {"id":1730899979094571,"params":{"chainId":"eip155:1","request":{"method":"eth_sendTransaction","params":[{"from":"0x123","gasLimit":"0x5028","maxFeePerGas":"0x2540be400","maxPriorityFeePerGas":"0x3b9aca00","to":"0x0c54FcCd2e384b4BB6f2E405Bf5Cbc15a017AaFb","value":"0x0"}]}},"topic":"147fa9ddffcf9d782b5e002eb36b041e43a1db2bad79a598da6926105ce6680f","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + + executeRequest(signEvent) + + compare(componentUnderTest.store.sendTransactionCalls.length, 1, "Send transaction should be called") + componentUnderTest.sdk.sessionRequestUserAnswerResult(signEvent.topic, signEvent.id, true, null) + + compare(componentUnderTest.signCompletedSpy.count, 1, "Sign completed signal should be emitted") + } + + function reject_sign() { + const signEvent = {"id":1730896495619543,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x123","{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + populateDAppData(signEvent.topic) + componentUnderTest.sdk.sessionRequestEvent(signEvent) + // Execute the request + const request = componentUnderTest.requests.get(0) + request.requestItem.rejected(false) + + compare(componentUnderTest.sdk.rejectSessionRequestCalls.length, 1, "Reject session request should be called") + compare(componentUnderTest.rejectedSpy.count, 1, "Rejected signal should be emitted") + compare(componentUnderTest.signCompletedSpy.count, 0, "Sign completed signal should not be emitted") + } + + function test_authFailed() { + const signEvent = {"id":1730896495619543,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x123","{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + populateDAppData(signEvent.topic) + componentUnderTest.sdk.sessionRequestEvent(signEvent) + // Execute the request + const request = componentUnderTest.requests.get(0) + + request.requestItem.authFailed() + compare(componentUnderTest.sdk.rejectSessionRequestCalls.length, 1, "Reject session request should be called") + compare(componentUnderTest.rejectedSpy.count, 1, "Rejected signal should be emitted") + compare(componentUnderTest.signCompletedSpy.count, 0, "Sign completed signal should not be emitted") + } + + function test_signMessageFails() { + const signEvent = {"id":1730896495619543,"params":{"chainId":"eip155:1","request":{"method":"eth_signTypedData_v4","params":["0x123","{\"domain\":{\"chainId\":\"1\",\"name\":\"Ether Mail\",\"verifyingContract\":\"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\",\"version\":\"1\"},\"message\":{\"contents\":\"Hello, Bob!\",\"from\":{\"name\":\"Cow\",\"wallets\":[\"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826\",\"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF\"]},\"to\":[{\"name\":\"Bob\",\"wallets\":[\"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\",\"0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57\",\"0xB0B0b0b0b0b0B000000000000000000000000000\"]}],\"attachment\":\"0x\"},\"primaryType\":\"Mail\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"Group\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"members\",\"type\":\"Person[]\"}],\"Mail\":[{\"name\":\"from\",\"type\":\"Person\"},{\"name\":\"to\",\"type\":\"Person[]\"},{\"name\":\"contents\",\"type\":\"string\"},{\"name\":\"attachment\",\"type\":\"bytes\"}],\"Person\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"wallets\",\"type\":\"address[]\"}]}}"]}},"topic":"43a74a4c6c71e3ab67ef80283dc43f392445642c8dce3dabe63f89ab83cfcfc3","verifyContext":{"verified":{"origin":"https://metamask.github.io/test-dapp/","validation":"UNKNOWN","verifyUrl":"https://verify.walletconnect.org"}}} + + populateDAppData(signEvent.topic) + componentUnderTest.sdk.sessionRequestEvent(signEvent) + // Execute the request + const request = componentUnderTest.requests.get(0) + request.requestItem.execute("passowrd", "pin") + + componentUnderTest.store.signingResult(signEvent.topic, signEvent.id, "") + compare(componentUnderTest.sdk.rejectSessionRequestCalls.length, 1, "Reject session request should be called") + compare(componentUnderTest.rejectedSpy.count, 1, "Rejected signal should be emitted") + compare(componentUnderTest.signCompletedSpy.count, 0, "Sign completed signal should not be emitted") + } + } +} \ No newline at end of file diff --git a/storybook/qmlTests/tests/tst_WCDAppsProvider.qml b/storybook/qmlTests/tests/tst_WCDAppsProvider.qml new file mode 100644 index 0000000000..92b6f28f1c --- /dev/null +++ b/storybook/qmlTests/tests/tst_WCDAppsProvider.qml @@ -0,0 +1,133 @@ +import QtQuick 2.15 +import QtTest 1.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import shared.stores 1.0 + +Item { + id: root + + Component { + id: wcDAppsProviderComponent + WCDappsProvider { + id: wcDAppsProvider + readonly property SignalSpy connectedSpy: SignalSpy { target: wcDAppsProvider; signalName: "connected" } + readonly property SignalSpy disconnectedSpy: SignalSpy { target: wcDAppsProvider; signalName: "disconnected" } + supportedAccountsModel: ListModel { + ListElement { + address: "0x123" + } + } + store: DAppsStore { + signal dappsListReceived(string dappsJson) + + property var addWalletConnectSessionCalls: [] + function addWalletConnectSession(sessionJson) { + addWalletConnectSessionCalls.push(sessionJson) + } + + property var deactivateWalletConnectSessionCalls: [] + function deactivateWalletConnectSession(topic) { + deactivateWalletConnectSessionCalls.push(topic) + } + + property var updateWalletConnectSessionsCalls: [] + function updateWalletConnectSessions(topics) { + updateWalletConnectSessionsCalls.push(topics) + } + + property var getDAppsCalls: [] + function getDapps() { + getDAppsCalls.push(true) + return [] + } + } + sdk: WalletConnectSDKBase { + id: sdk + enabled: true + projectId: "" + property bool sdkReady: true + property var activeSessions: ({}) + getActiveSessions: function(callback) { + callback(sdk.activeSessions) + } + } + } + } + + function buildSession(dappUrl, dappName, dappIcon, proposalId, account, chains) { + let sessionTemplate = (dappUrl, dappName, dappIcon, proposalId, eipAccount, eipChains) => { + return { + peer: { + metadata: { + description: "-", + icons: [ + dappIcon + ], + name: dappName, + url: dappUrl + } + }, + namespaces: { + eip155: { + accounts: [eipAccount], + chains: eipChains + } + }, + pairingTopic: proposalId, + topic: dappUrl + }; + } + + const eipAccount = account ? `eip155:${account}` : "" + const eipChains = chains ? chains.map((chain) => `eip155:${chain}`) : [] + + return sessionTemplate(dappUrl, dappName, dappIcon, proposalId, eipAccount, eipChains) + } + + TestCase { + id: wcDAppsProviderTest + + property WCDappsProvider componentUnderTest: null + function init() { + componentUnderTest = createTemporaryObject(wcDAppsProviderComponent, root) + } + + function test_addRemoveSession() { + const newSession = buildSession("https://example.com", "Example", "https://example.com/icon.png", "123", "0x123", ["1"]) + componentUnderTest.sdk.activeSessions["https://example.com"] = newSession + componentUnderTest.sdk.approveSessionResult("requestID", newSession, null) + + compare(componentUnderTest.store.addWalletConnectSessionCalls.length, 1, "addWalletConnectSession should be called once") + compare(componentUnderTest.connectedSpy.count, 1, "Connected signal should be emitted once") + compare(componentUnderTest.connectedSpy.signalArguments[0][0], "requestID", "Connected signal should have correct proposalId") + compare(componentUnderTest.connectedSpy.signalArguments[0][1], "https://example.com", "Connected signal should have correct topic") + compare(componentUnderTest.connectedSpy.signalArguments[0][2], "https://example.com", "Connected signal should have correct dAppUrl") + + const dapp = componentUnderTest.getByTopic("https://example.com") + verify(!!dapp, "DApp should be found") + compare(dapp.name, "Example", "DApp should have correct name") + compare(dapp.url, "https://example.com", "DApp should have correct url") + compare(dapp.iconUrl, "https://example.com/icon.png", "DApp should have correct iconUrl") + compare(dapp.topic, "https://example.com", "DApp should have correct topic") + compare(dapp.connectorId, componentUnderTest.connectorId, "DApp should have correct connectorId") + compare(dapp.accountAddresses.count, 1, "DApp should have correct accountAddresses count") + compare(dapp.accountAddresses.get(0).address, "0x123", "DApp should have correct accountAddresses address") + compare(dapp.rawSessions.count, 1, "DApp should have correct rawSessions count") + + componentUnderTest.sdk.sessionDelete("https://example.com", "") + compare(componentUnderTest.store.deactivateWalletConnectSessionCalls.length, 1, "deactivateWalletConnectSession should be called once") + compare(componentUnderTest.disconnectedSpy.count, 1, "Disconnected signal should be emitted once") + compare(componentUnderTest.disconnectedSpy.signalArguments[0][0], "https://example.com", "Disconnected signal should have correct topic") + compare(componentUnderTest.disconnectedSpy.signalArguments[0][1], "https://example.com", "Disconnected signal should have correct dAppUrl") + } + + function test_disabledSDK() { + componentUnderTest.sdk.enabled = false + componentUnderTest.sdk.approveSessionResult("requestID", buildSession("https://example.com", "Example", "https://example.com/icon.png", "123", "0x123", ["1"]), "") + compare(componentUnderTest.connectedSpy.count, 0, "Connected signal should not be emitted") + componentUnderTest.sdk.sessionDelete("https://example.com", "") + compare(componentUnderTest.disconnectedSpy.count, 0, "Disconnected signal should not be emitted") + } + } +} \ No newline at end of file diff --git a/storybook/stubs/shared/stores/BrowserConnectStore.qml b/storybook/stubs/shared/stores/BrowserConnectStore.qml new file mode 100644 index 0000000000..2b87551bfc --- /dev/null +++ b/storybook/stubs/shared/stores/BrowserConnectStore.qml @@ -0,0 +1,4 @@ +import QtQuick 2.15 + +QtObject { +} \ No newline at end of file diff --git a/storybook/stubs/shared/stores/qmldir b/storybook/stubs/shared/stores/qmldir index d7a633428c..73ce556341 100644 --- a/storybook/stubs/shared/stores/qmldir +++ b/storybook/stubs/shared/stores/qmldir @@ -8,3 +8,4 @@ PermissionsStore 1.0 PermissionsStore.qml ProfileStore 1.0 ProfileStore.qml RootStore 1.0 RootStore.qml UtilsStore 1.0 UtilsStore.qml +BrowserConnectStore 1.0 BrowserConnectStore.qml diff --git a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml index 24253bc623..5d9a329efa 100644 --- a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml +++ b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml @@ -69,12 +69,11 @@ DappsComboBox { signal disconnectRequested(string connectionId) signal pairingRequested(string uri) signal pairingValidationRequested(string uri) - signal connectionAccepted(var pairingId, var chainIds, string selectedAccount) - signal connectionDeclined(var pairingId) + signal connectionAccepted(string pairingId, var chainIds, string selectedAccount) + signal connectionDeclined(string pairingId) signal signRequestAccepted(string connectionId, string requestId) signal signRequestRejected(string connectionId, string requestId) - - signal subscribeForFeeUpdates(string connectionId, string requestId) + signal signRequestIsLive(string connectionId, string requestId) /// Response to pairingValidationRequested function pairingValidated(validationState) { @@ -349,7 +348,7 @@ DappsComboBox { hasExpiryDate: !!request.expirationTimestamp onOpened: { - root.subscribeForFeeUpdates(request.topic, request.requestId) + root.signRequestIsLive(request.topic, request.requestId) } onClosed: { diff --git a/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml b/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml index 2aa3dba800..f91a92e16c 100644 --- a/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml +++ b/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml @@ -137,38 +137,38 @@ Item { id: dappsWorkflow Layout.alignment: Qt.AlignTop - readonly property WalletConnectService wcService: Global.walletConnectService + readonly property DAppsService dAppsService: Global.dAppsService spacing: 8 visible: !root.walletStore.showSavedAddresses - && (wcService.walletConnectFeatureEnabled || wcService.connectorFeatureEnabled) - && wcService.serviceAvailableToCurrentAddress - enabled: !!wcService && wcService.isServiceOnline + && (dAppsService.walletConnectFeatureEnabled || dAppsService.connectorFeatureEnabled) + && dAppsService.serviceAvailableToCurrentAddress + enabled: !!dAppsService && dAppsService.isServiceOnline - walletConnectEnabled: wcService.walletConnectFeatureEnabled - connectorEnabled: wcService.connectorFeatureEnabled + walletConnectEnabled: dAppsService.walletConnectFeatureEnabled + connectorEnabled: dAppsService.connectorFeatureEnabled loginType: root.loginType selectedAccountAddress: root.walletStore.selectedAddress - model: wcService.dappsModel - accountsModel: wcService.validAccounts - networksModel: wcService.flatNetworks - sessionRequestsModel: wcService.sessionRequestsModel + model: dAppsService.dappsModel + accountsModel: root.walletStore.nonWatchAccounts + networksModel: root.walletStore.filteredFlatModel + sessionRequestsModel: dAppsService.sessionRequestsModel - formatBigNumber: (number, symbol, noSymbolOption) => wcService.walletRootStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption) + formatBigNumber: (number, symbol, noSymbolOption) => root.walletStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption) - onDisconnectRequested: (connectionId) => wcService.disconnectDapp(connectionId) - onPairingRequested: (uri) => wcService.pair(uri) - onPairingValidationRequested: (uri) => wcService.validatePairingUri(uri) - onConnectionAccepted: (pairingId, chainIds, selectedAccount) => wcService.approvePairSession(pairingId, chainIds, selectedAccount) - onConnectionDeclined: (pairingId) => wcService.rejectPairSession(pairingId) - onSignRequestAccepted: (connectionId, requestId) => wcService.sign(connectionId, requestId) - onSignRequestRejected: (connectionId, requestId) => wcService.rejectSign(connectionId, requestId, false /*hasError*/) - onSubscribeForFeeUpdates: (connectionId, requestId) => wcService.subscribeForFeeUpdates(connectionId, requestId) + onDisconnectRequested: (connectionId) => dAppsService.disconnectDapp(connectionId) + onPairingRequested: (uri) => dAppsService.pair(uri) + onPairingValidationRequested: (uri) => dAppsService.validatePairingUri(uri) + onConnectionAccepted: (pairingId, chainIds, selectedAccount) => dAppsService.approvePairSession(pairingId, chainIds, selectedAccount) + onConnectionDeclined: (pairingId) => dAppsService.rejectPairSession(pairingId) + onSignRequestAccepted: (connectionId, requestId) => dAppsService.sign(connectionId, requestId) + onSignRequestRejected: (connectionId, requestId) => dAppsService.rejectSign(connectionId, requestId, false /*hasError*/) + onSignRequestIsLive: (connectionId, requestId) => dAppsService.signRequestIsLive(connectionId, requestId) Connections { - target: dappsWorkflow.wcService + target: dappsWorkflow.dAppsService function onPairingValidated(validationState) { dappsWorkflow.pairingValidated(validationState) } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/BCDappsProvider.qml b/ui/app/AppLayouts/Wallet/services/dapps/BCDappsProvider.qml index 66419d4c6f..cbfaad6f08 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/BCDappsProvider.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/BCDappsProvider.qml @@ -9,64 +9,86 @@ import utils 1.0 DAppsModel { id: root - required property BrowserConnectStore store + required property WalletConnectSDKBase bcSDK readonly property int connectorId: Constants.StatusConnect - property bool enabled: true + readonly property bool enabled: bcSDK.enabled + + signal connected(string pairingId, string topic, string dAppUrl) + signal disconnected(string topic, string dAppUrl) Connections { - target: root.store + target: root.bcSDK enabled: root.enabled - function onConnected(dappJson) { - const dapp = JSON.parse(dappJson) - const { url, name, icon, sharedAccount } = dapp - - if (!url) { - console.warn(invalidDAppUrlError) + function onSessionDelete(topic, err) { + const dapp = root.getByTopic(topic) + if (!dapp) { + console.warn("DApp not found for topic - cannot delete session", topic) return } - root.append({ - name, - url, - iconUrl: icon, - topic: url, - connectorId: root.connectorId, - accountAddresses: [{address: sharedAccount}], - rawSessions: [dapp] - }) + + root.remove(topic) + root.disconnected(topic, dapp.url) } - function onDisconnected(dappJson) { - const dapp = JSON.parse(dappJson) - const { url } = dapp - - if (!url) { - console.warn(invalidDAppUrlError) + function onApproveSessionResult(proposalId, session, error) { + if (error) { + console.warn("Failed to approve session", error) return } - root.remove(dapp.url) + + const dapp = d.sessionToDApp(session) + root.append(dapp) + root.connected(proposalId, dapp.topic, dapp.url) + } + } + + QtObject { + id: d + function sessionToDApp(session) { + const dapp = session.peer.metadata + if (!!dapp.icons && dapp.icons.length > 0) { + dapp.iconUrl = dapp.icons[0] + } else { + dapp.iconUrl = "" + } + const accounts = DAppsHelpers.getAccountsInSession(session) + dapp.accountAddresses = accounts.map(account => ({address: account})) + dapp.topic = session.topic + dapp.rawSessions = [session] + dapp.connectorId = root.connectorId + return dapp + } + function getPersistedDapps() { + if (!root.enabled) { + return [] + } + let dapps = [] + root.bcSDK.getActiveSessions((allSessions) => { + if (!allSessions) { + return + } + + for (const sessionID in allSessions) { + const session = allSessions[sessionID] + const dapp = sessionToDApp(session) + dapps.push(dapp) + } + }) + return dapps + } + + function resetModel() { + root.clear() + const dapps = d.getPersistedDapps() + for (let i = 0; i < dapps.length; i++) { + root.append(dapps[i]) + } } } Component.onCompleted: { - if (root.enabled) { - const dappsStr = root.store.getDApps() - if (dappsStr) { - const dapps = JSON.parse(dappsStr) - dapps.forEach(dapp => { - const { url, name, iconUrl, sharedAccount } = dapp - root.append({ - name, - url, - iconUrl, - topic: url, - connectorId: root.connectorId, - accountAddresses: [{address: sharedAccount}], - rawSessions: [dapp] - }) - }) - } - } + d.resetModel() } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsModel.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsModel.qml index 741b477510..b4eb653298 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsModel.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsModel.qml @@ -4,6 +4,7 @@ import StatusQ.Core.Utils 0.1 QObject { id: root + objectName: "DAppsModel" // RoleNames // name: string // url: string @@ -15,14 +16,22 @@ QObject { // rawSessions: [{session: object}] readonly property ListModel model: ListModel {} + // Appending a new DApp to the model + // Required properties: url, topic, connectorId, accountAddresses + // Optional properties: name, iconUrl, chains, rawSessions function append(dapp) { try { - const {name, url, iconUrl, topic, accountAddresses, connectorId, rawSessions } = dapp - if (!name || !url || !iconUrl || !topic || !connectorId || !accountAddresses || !rawSessions) { + let {name, url, iconUrl, topic, accountAddresses, connectorId, rawSessions } = dapp + if (!url || !topic || !connectorId || !accountAddresses) { console.warn("DAppsModel - Failed to append dapp, missing required fields", JSON.stringify(dapp)) return } + name = name || "" + iconUrl = iconUrl || "" + accountAddresses = accountAddresses || [] + rawSessions = rawSessions || [] + root.model.append({ name, url, @@ -38,12 +47,21 @@ QObject { } function remove(topic) { - for (let i = 0; i < root.model.count; i++) { - const dapp = root.model.get(i) - if (dapp.topic == topic) { - root.model.remove(i) - break - } + const { dapp, index, sessionIndex } = findDapp(topic) + if (!dapp) { + console.warn("DAppsModel - Failed to remove dapp, not found", topic) + return + } + + if (dapp.rawSessions.count === 1) { + root.model.remove(index) + return + } + + const rawSession = dapp.rawSessions.get(sessionIndex) + dapp.rawSessions.remove(sessionIndex) + if (rawSession.topic == dapp.topic) { + root.model.setProperty(index, "topic", dapp.rawSessions.get(0).topic) } } @@ -52,20 +70,36 @@ QObject { } function getByTopic(topic) { + const dappTemplate = (dapp) => { + return { + name: dapp.name, + url: dapp.url, + iconUrl: dapp.iconUrl, + topic: dapp.topic, + connectorId: dapp.connectorId, + accountAddresses: dapp.accountAddresses, + rawSessions: dapp.rawSessions + } + } + + const { dapp } = findDapp(topic) + if (!dapp) { + return null + } + return dappTemplate(dapp) + } + + function findDapp(topic) { for (let i = 0; i < root.model.count; i++) { const dapp = root.model.get(i) - if (dapp.topic == topic) { - return { - name: dapp.name, - url: dapp.url, - iconUrl: dapp.iconUrl, - topic: dapp.topic, - connectorId: dapp.connectorId, - accountAddresses: dapp.accountAddresses, - rawSessions: dapp.rawSessions + for (let j = 0; j < dapp.rawSessions.count; j++) { + if (dapp.rawSessions.get(j).topic == topic) { + return { dapp, index: i, sessionIndex: j } + break } } } - return null + + return { dapp: null, index: -1, sessionIndex: -1 } } } \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml new file mode 100644 index 0000000000..213a8b893e --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml @@ -0,0 +1,309 @@ +import QtQuick 2.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.plugins 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 +import AppLayouts.Wallet.stores 1.0 as WalletStore + +import StatusQ.Core.Utils 0.1 as SQUtils + +import shared.stores 1.0 +import utils 1.0 + +/// Component that provides the dapps integration for the wallet. +/// It provides the following features: +/// - WalletConnect integration +/// - WalletConnect pairing +/// - WalletConnect sessions management +/// - WalletConnect signing requests +/// - WalletConnect SIWE +/// - WalletConnect online status +/// - BrowserConnect integration +/// - BrowserConnect pairing +/// - BrowserConnect - access to persistent sessions +/// - BrowserConnect - access to persistent signing requests +/// - BrowserConnect signing requests +/// - BrowserConnect online status +SQUtils.QObject { + id: root + + // SDKs providing the DApps API + required property WalletConnectSDKBase wcSdk + required property WalletConnectSDKBase bcSdk + + // DApps shared store - used for wc peristence and signing requests/transactions + required property DAppsStore store + required property CurrenciesStore currenciesStore + + // Required roles: address + required property var accountsModel + // Required roles: chainId, layer, isOnline + required property var networksModel + // Required roles: tokenKey, balances + required property var groupedAccountAssetsModel + + readonly property alias requestsModel: requests + readonly property alias dappsModel: dappConnections.dappsModel + readonly property bool enabled: wcSdk.enabled || bcSdk.enabled + readonly property bool isServiceOnline: chainsSupervisorPlugin.anyChainAvailable && (wcSdk.sdkReady || bcSdk.enabled) + + // Connection signals + /// Emitted when a new DApp requests a connection + signal connectDApp(var chains, string dAppUrl, string dAppName, string dAppIcon, string key) + /// Emitted when a new DApp is connected + signal dappConnected(string proposalId, string newTopic, string url, int connectorId) + /// Emitted when a DApp is disconnected + signal dappDisconnected(string topic, string url) + /// Emitted when a new DApp fails to connect + signal newConnectionFailed(string key, string dappUrl, var error) + + // Pairing signals + signal pairingValidated(int validationState) + signal pairingResponse(int state) // Maps to Pairing.errors + + // Sign request signals + signal signCompleted(string topic, string id, bool userAccepted, string error) + signal siweCompleted(string topic, string id, string error) + + /// WalletConnect pairing + /// @param uri - the pairing URI to pair + /// Result is emitted via the pairingResponse signal + /// A new session proposal is expected to be emitted if the pairing is successful + function pair(uri) { + return wcSdk.pair(uri) + } + + /// Approves or rejects the session proposal. App response to `connectDApp` + /// @param key - the key of the session proposal + /// @param approvedChainIds - array containing the chainIds that the user approved + /// @param accountAddress - the address of the account that approved the session + function approvePairSession(key, approvedChainIds, accountAddress) { + if (siwePlugin.connectionRequests.has(key.toString())) { + siwePlugin.accept(key, approvedChainIds, accountAddress) + siwePlugin.connectionRequests.delete(key.toString()) + return + } + dappConnections.connect(key, approvedChainIds, accountAddress) + } + + /// Rejects the session proposal. App response to `connectDApp` + /// @param id - the id of the session proposal + function rejectPairSession(id) { + if (siwePlugin.connectionRequests.has(id.toString())) { + siwePlugin.reject(id) + siwePlugin.connectionRequests.delete(id.toString()) + return + } + dappConnections.reject(id) + } + + /// Disconnects the WC session with the given topic. Expected `dappDisconnected` signal + /// @param sessionTopic - the topic of the session to disconnect + function disconnectSession(sessionTopic) { + dappConnections.disconnect(sessionTopic) + } + + /// Validates the pairing URI and emits the pairingValidated signal. Expected `pairingValidated` signal + /// Async function + /// @param uri - the pairing URI to validate + function validatePairingUri(uri){ + const info = DAppsHelpers.extractInfoFromPairUri(uri) + wcSdk.getActiveSessions((sessions) => { + // Check if the URI is already paired + let validationState = Pairing.errors.uriOk + for (const key in sessions) { + if (sessions[key].pairingTopic === info.topic) { + validationState = Pairing.errors.alreadyUsed + break + } + } + + // Check if expired + if (validationState === Pairing.errors.uriOk) { + const now = (new Date().getTime())/1000 + if (info.expiry < now) { + validationState = Pairing.errors.expired + } + } + + root.pairingValidated(validationState) + }); + } + + /// Returns the DApp with the given topic + /// @param topic - the topic of the DApp to return + /// @return the DApp with the given topic + /// DApp { + /// name: string + /// url: string + /// iconUrl: string + /// topic: string + /// connectorId: int + /// accountAddressses: [{address: string}] + /// chains: string + /// rawSessions: [{session: object}] + /// } + function getDApp(topic) { + return SQUtils.ModelUtils.getFirstModelEntryIf(dappsModel, (dapp) => { + return dapp.topic === topic + SQUtils.ModelUtils.getFirstModelEntryIf(dapp.rawSessions, (session) => { + return session.topic === topic + }) + }) + } + + DAppConnectionsPlugin { + id: dappConnections + wcSDK: root.wcSdk + bcSDK: root.bcSdk + dappsStore: root.store + accountsModel: root.accountsModel + networksModel: root.networksModel + + onConnected: (proposalId, topic, url, connectorId) => { + root.dappConnected(proposalId, topic, url, connectorId) + } + onDisconnected: (topic, url) => { + root.dappDisconnected(topic, url) + } + onNewConnectionProposed: (key, chains, dAppUrl, dAppName, dAppIcon) => { + root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key) + } + onNewConnectionFailed: (key, dappUrl, error) => { + root.newConnectionFailed(key, dappUrl, error) + } + } + + SessionRequestsModel { + id: requests + } + + ChainsSupervisorPlugin { + id: chainsSupervisorPlugin + + sdk: root.wcSdk + networksModel: root.networksModel + } + + Connections { + target: root.wcSdk + enabled: root.wcSdk.enabled + + function onPairResponse(ok) { + root.pairingResponse(ok) + } + } + + SiweRequestPlugin { + id: siwePlugin + + readonly property var connectionRequests: new Map() + sdk: root.wcSdk + store: root.store + accountsModel: root.accountsModel + networksModel: root.networksModel + + onRegisterSignRequest: (request) => { + requests.enqueue(request) + } + + onUnregisterSignRequest: (requestId) => { + const request = requests.findById(requestId) + if (request === null) { + console.error("SiweRequestPlugin::onUnregisterSignRequest: Error finding event for requestId", requestId) + return + } + requests.removeRequest(request.topic, requestId) + } + + onConnectDApp: (chains, dAppUrl, dAppName, dAppIcon, key) => { + siwePlugin.connectionRequests.set(key.toString(), {chains, dAppUrl, dAppName, dAppIcon}) + root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key) + } + + onSiweFailed: (id, error, topic) => { + root.siweCompleted(topic, id, error) + } + + onSiweSuccessful: (id, topic) => { + d.lookupSession(topic, function(session) { + // Persist session + if(!root.store.addWalletConnectSession(JSON.stringify(session))) { + console.error("Failed to persist session") + } + + root.siweCompleted(topic, id, "") + }) + } + + function accept(key, approvedChainIds, accountAddress) { + const approvedNamespaces = JSON.parse( + DAppsHelpers.buildSupportedNamespaces(approvedChainIds, + [accountAddress], + SessionRequest.getSupportedMethods())) + siwePlugin.connectionApproved(key, approvedNamespaces) + } + function reject(key) { + siwePlugin.connectionRejected(key) + } + } + + SQUtils.QObject { + id: d + + function lookupSession(topicToLookup, callback) { + wcSdk.getActiveSessions((res) => { + Object.keys(res).forEach((topic) => { + if (topic === topicToLookup) { + let session = res[topic] + callback(session) + } + }) + }) + } + } + + // bcSignRequestPlugin and wcSignRequestPlugin are used to handle sign requests + // Almost identical, and it's worth extracting in an inline component, but Qt5.15.2 doesn't support it + SignRequestPlugin { + id: bcSignRequestPlugin + + sdk: root.bcSdk + groupedAccountAssetsModel: root.groupedAccountAssetsModel + networksModel: root.networksModel + accountsModel: root.accountsModel + currentCurrency: root.currenciesStore.currentCurrency + store: root.store + requests: root.requestsModel + dappsModel: root.dappsModel + + getFiatValue: (value, currency) => { + return root.currenciesStore.getFiatValue(value, currency) + } + + onSignCompleted: (topic, id, userAccepted, error) => { + root.signCompleted(topic, id, userAccepted, error) + } + } + + SignRequestPlugin { + id: wcSignRequestPlugin + + sdk: root.wcSdk + groupedAccountAssetsModel: root.groupedAccountAssetsModel + networksModel: root.networksModel + accountsModel: root.accountsModel + currentCurrency: root.currenciesStore.currentCurrency + store: root.store + requests: root.requestsModel + dappsModel: root.dappsModel + + getFiatValue: (value, currency) => { + return root.currenciesStore.getFiatValue(value, currency) + } + + onSignCompleted: (topic, id, userAccepted, error) => { + root.signCompleted(topic, id, userAccepted, error) + } + } +} diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml index 543ef0deca..0f1931ef86 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml @@ -1127,6 +1127,8 @@ SQUtils.QObject { } onAuthFailed: () => { + d.unsubscribeForFeeUpdates(request.topic, request.requestId) + sdk.rejectSessionRequest(request.topic, request.requestId, true) const appDomain = SQUtils.StringUtils.extractDomainFromLink(request.dappUrl) const methodStr = SessionRequest.methodToUserString(request.method) if (!methodStr) { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsService.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsService.qml new file mode 100644 index 0000000000..8f7cd1c197 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsService.qml @@ -0,0 +1,271 @@ +import QtQuick 2.15 + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils + +import AppLayouts.Wallet 1.0 +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 +import shared.popups.walletconnect 1.0 + +import SortFilterProxyModel 0.2 +import utils 1.0 + +import "types" + +SQUtils.QObject { + id: root + + required property DAppsModule dappsModule + required property string selectedAddress + required property var accountsModel + + property bool walletConnectFeatureEnabled: true + property bool connectorFeatureEnabled: true + + // Output properties + /// Model contaning all dApps available for the currently selected account + readonly property var dappsModel: d.filteredDappsModel + /// Model containig the dApps session requests to be resolved by the user + readonly property SessionRequestsModel sessionRequestsModel: dappsModule.requestsModel + /// Service can interact with the current address selection + /// Default value: true + readonly property bool serviceAvailableToCurrentAddress: !root.selectedAddress || + SQUtils.ModelUtils.contains(root.accountsModel, "address", root.selectedAddress, Qt.CaseInsensitive) + + readonly property bool isServiceOnline: dappsModule.isServiceOnline + + // signals + signal connectDApp(var dappChains, url dappUrl, string dappName, url dappIcon, string key) + // Emitted as a response to DAppsService.approveSession + // @param key The key of the session proposal + // @param error The error message + // @param topic The new topic of the session + signal approveSessionResult(string key, var error, string topic) + // Emitted when a new session is requested by a dApp + signal sessionRequest(string id) + // Emitted when the services requests to display a toast message + // @param message The message to display + // @param type The type of the message. Maps to Constants.ephemeralNotificationType + signal displayToastMessage(string message, int type) + // Emitted as a response to DAppsService.validatePairingUri or other DAppsService.pair + // and DAppsService.approvePair errors + signal pairingValidated(int validationState) + + // methods + /// Triggers the signing process for the given session request + /// @param topic The topic of the session + /// @param id The id of the session request + function sign(topic, id) { + // The authentication triggers the signing process + // authenticate -> sign -> inform the dApp + d.sign(topic, id) + } + + function rejectSign(topic, id, hasError) { + d.rejectSign(topic, id, hasError) + } + + function signRequestIsLive(topic, id) { + d.signRequestIsLive(topic, id) + } + + /// Validates the pairing URI + function validatePairingUri(uri) { + d.validatePairingUri(uri) + } + + /// Initiates the pairing process with the given URI + function pair(uri) { + timeoutTimer.start() + dappsModule.pair(uri) + } + + /// Approves or rejects the session proposal + function approvePairSession(key, approvedChainIds, accountAddress) { + dappsModule.approvePairSession(key, approvedChainIds, accountAddress) + } + + + /// Rejects the session proposal + function rejectPairSession(id) { + dappsModule.rejectPairSession(id) + } + + /// Disconnects the dApp with the given topic + /// @param topic The topic of the dApp + /// @param source The source of the dApp; either "walletConnect" or "connector" + function disconnectDapp(topic) { + d.disconnectDapp(topic) + } + + SQUtils.QObject { + id: d + + readonly property var filteredDappsModel: SortFilterProxyModel { + id: dappsFilteredModel + objectName: "DAppsModelFiltered" + sourceModel: root.dappsModule.dappsModel + readonly property string selectedAddress: root.selectedAddress + + filters: FastExpressionFilter { + enabled: !!dappsFilteredModel.selectedAddress + + function isAddressIncluded(accountAddressesSubModel, selectedAddress) { + if (!accountAddressesSubModel) { + return false + } + const addresses = SQUtils.ModelUtils.modelToFlatArray(accountAddressesSubModel, "address") + return addresses.includes(selectedAddress) + } + expression: isAddressIncluded(model.accountAddresses, dappsFilteredModel.selectedAddress) + + expectedRoles: "accountAddresses" + } + } + + function disconnectDapp(connectionId) { + dappsModule.disconnectSession(connectionId) + } + + function validatePairingUri(uri) { + // Check if emoji inside the URI + if(Constants.regularExpressions.emoji.test(uri)) { + root.pairingValidated(Pairing.errors.tooCool) + return + } else if(!DAppsHelpers.validURI(uri)) { + root.pairingValidated(Pairing.errors.invalidUri) + return + } + + dappsModule.validatePairingUri(uri) + } + + function signRequestIsLive(topic, id) { + const request = root.sessionRequestsModel.findRequest(topic, id) + if (!request) { + console.error("Session request not found") + return + } + request.setActive() + } + + function sign(topic, id) { + const request = root.sessionRequestsModel.findRequest(topic, id) + if (!request) { + console.error("Session request not found") + return + } + request.accept() + } + + function rejectSign(topic, id, hasError) { + const request = root.sessionRequestsModel.findRequest(topic, id) + if (!request) { + console.error("Session request not found") + return + } + request.reject(hasError) + } + + function reportPairErrorState(state) { + timeoutTimer.stop() + root.pairingValidated(state) + } + } + + Connections { + target: root.dappsModule + enabled: root.walletConnectFeatureEnabled || root.connectorFeatureEnabled + function onPairingValidated(state) { + timeoutTimer.stop() + root.pairingValidated(state) + } + + function onPairingResponse(key, state) { + timeoutTimer.stop() + if (state != Pairing.errors.uriOk) { + d.reportPairErrorState(state) + } + } + + function onConnectDApp(dappChains, dappUrl, dappName, dappIcon, key) { + root.connectDApp(dappChains, dappUrl, dappName, dappIcon, key) + } + + function onDappConnected(proposal, topic, url, connectorId) { + const dappDomain = SQUtils.StringUtils.extractDomainFromLink(url) + const connectorName = connectorId === Constants.WalletConnect ? "WalletConnect" : "Status Connector" + root.displayToastMessage(qsTr("Connected to %1 via %2").arg(dappDomain).arg(connectorName), Constants.ephemeralNotificationType.success) + root.approveSessionResult(proposal, null, topic) + } + + function onDappDisconnected(topic, url) { + const appDomain = SQUtils.StringUtils.extractDomainFromLink(url) + root.displayToastMessage(qsTr("Disconnected from %1").arg(appDomain), Constants.ephemeralNotificationType.success) + } + + function onNewConnectionFailed(key, url, error) { + timeoutTimer.stop() + const dappDomain = SQUtils.StringUtils.extractDomainFromLink(url) + if (error === Pairing.errors.userRejected) { + root.displayToastMessage(qsTr("Connection request for %1 was rejected").arg(dappDomain), Constants.ephemeralNotificationType.success) + } + if (error === Pairing.errors.rejectFailed) { + root.displayToastMessage(qsTr("Failed to reject connection request for %1").arg(dappDomain), Constants.ephemeralNotificationType.danger) + } + d.reportPairErrorState(error) + root.approveSessionResult(key, error, "") + } + + function onSignCompleted(topic, id, userAccepted, error) { + const request = root.sessionRequestsModel.findRequest(topic, id) + if (!request) { + console.error("Session request not found") + return + } + + const methodStr = SessionRequest.methodToUserString(request.method) + const appUrl = request.dappUrl + const appDomain = SQUtils.StringUtils.extractDomainFromLink(appUrl) + if (!methodStr) { + console.error("Error finding user string for method", request.method) + return + } + + if (error) { + root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(appDomain), Constants.ephemeralNotificationType.danger) + return + } + + const requestExpired = request.isExpired() + if (requestExpired) { + root.displayToastMessage("%1 sign request timed out".arg(appDomain), Constants.ephemeralNotificationType.normal) + return + } + + const actionStr = userAccepted ? qsTr("accepted") : qsTr("rejected") + root.displayToastMessage("%1 %2 %3".arg(appDomain).arg(methodStr).arg(actionStr), Constants.ephemeralNotificationType.success) + } + + function onSiweCompleted(topic, id, userAccepted, error) { + if (error) { + root.displayToastMessage(error, Constants.ephemeralNotificationType.danger) + } + root.approveSessionResult(id, error, topic) + } + } + + // Timeout for the corner case where the URL was already dismissed and the SDK doesn't respond with an error nor advances with the proposal + Timer { + id: timeoutTimer + + interval: 10000 // (10 seconds) + running: false + repeat: false + + onTriggered: { + d.reportPairErrorState(Pairing.errors.unknownError) + } + } +} diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml b/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml index f9c7d76436..f4e5290bdd 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml @@ -21,773 +21,126 @@ import utils 1.0 import "types" -// Act as another layer of abstraction to the WalletConnectSDKBase -// Quick hack until the WalletConnectSDKBase could be refactored to a more generic DappProviderBase with API to match -// the UX requirements +/// Act as another layer of abstraction to the WalletConnectSDKBase +/// Converts the store requests into WalletConnect standard requests WalletConnectSDKBase { id: root - - required property WalletConnectService wcService - required property var walletStore - required property DAppsStore store - required property int loginType - - property var controller - property var dappInfo: null - property var txArgs: null - property bool sdkReady: true - property bool active: true - property string requestId: "" - property alias requestsModel: requests - - readonly property string invalidDAppUrlError: "Invalid dappInfo: URL is missing" - readonly property string invalidDAppTopicError: "Invalid dappInfo: failed to parse topic" + + required property BrowserConnectStore store + /// Required roles: chainId + required property var networksModel + /// Required roles: address + required property var accountsModel projectId: "" - implicitWidth: 1 - implicitHeight: 1 - - // TODO Refactor this code to avoid code duplication from Wallet Connect DAppsRequestHandler - // https://github.com/status-im/status-desktop/issues/15711 - QtObject { - id: d - - function sessionRequestEvent(event) { - let obj = d.resolveAsync(event) - if (obj === null) { - let error = true - controller.rejectTransactionSigning(root.requestId) - return - } - sessionRequestLoader.request = obj - requests.enqueue(obj) - } - - function resolveAsync(event) { - let method = event.params.request.method - let accountAddress = lookupAccountFromEvent(event, method) - if(!accountAddress) { - console.error("Error finding accountAddress for event", JSON.stringify(event)) - return null - } - let chainId = lookupNetworkFromEvent(event, method) - if(!chainId) { - console.error("Error finding network for event", JSON.stringify(event)) - return null - } - let data = extractMethodData(event, method) - if(!data) { - console.error("Error in event data lookup", JSON.stringify(event)) - return null - } - const interpreted = d.prepareData(method, data) - let enoughFunds = !isTransactionMethod(method) - let obj = sessionRequestComponent.createObject(null, { - event, - topic: event.topic, - requestId: event.id, - method, - accountAddress, - chainId, - data, - preparedData: interpreted.preparedData, - maxFeesText: "?", - maxFeesEthText: "?", - haveEnoughFunds: enoughFunds - }) - - if (obj === null) { - console.error("Error creating SessionRequestResolved for event") - return null - } - - // Check later to have a valid request object - if (!SessionRequest.getSupportedMethods().includes(method)) { - console.error("Unsupported method", method) - return null - } - - let session = getActiveSession(root.dappInfo) - - if (session === null) { - console.error("Connector.lookupSession: error finding session for requestId ", obj.requestId) - return - } - obj.resolveDappInfoFromSession(session) - - if (d.isTransactionMethod(method)) { - let tx = obj.data.tx - if (tx === null) { - console.error("Error cannot resolve tx object") - return null - } - let BigOps = SQUtils.AmountsArithmetic - let gasLimit = hexToGwei(tx.gasLimit) - - if (tx.gasPrice === null || tx.gasPrice === undefined) { - let maxFeePerGas = hexToGwei(tx.maxFeePerGas) - let maxPriorityFeePerGas = hexToGwei(tx.maxPriorityFeePerGas) - let totalMaxFees = BigOps.sum(maxFeePerGas, maxPriorityFeePerGas) - let maxFees = BigOps.times(gasLimit, totalMaxFees) - let maxFeesString = maxFees.toString() - obj.maxFeesText = maxFeesString - obj.maxFeesEthText = maxFeesString - obj.haveEnoughFunds = true - } else { - let gasPrice = hexToGwei(tx.gasPrice) - let maxFees = BigOps.times(gasLimit, gasPrice) - let maxFeesString = maxFees.toString() - obj.maxFeesText = maxFeesString - obj.maxFeesEthText = maxFeesString - obj.haveEnoughFunds = true - } - } - - return obj - } - - function getTxObject(method, data) { - let tx - if (method === SessionRequest.methods.signTransaction.name) { - tx = SessionRequest.methods.signTransaction.getTxObjFromData(data) - } else if (method === SessionRequest.methods.sendTransaction.name) { - tx = SessionRequest.methods.sendTransaction.getTxObjFromData(data) - } else { - console.error("Not a transaction method") - } - return tx - } - - // returns { - // preparedData, - // value // null or ETH Big number - // } - function prepareData(method, data) { - let payload = null - switch(method) { - case SessionRequest.methods.personalSign.name: { - payload = SessionRequest.methods.personalSign.getMessageFromData(data) - break - } - case SessionRequest.methods.sign.name: { - payload = SessionRequest.methods.sign.getMessageFromData(data) - break - } - case SessionRequest.methods.signTypedData_v4.name: { - const stringPayload = SessionRequest.methods.signTypedData_v4.getMessageFromData(data) - payload = JSON.stringify(JSON.parse(stringPayload), null, 2) - break - } - case SessionRequest.methods.signTypedData.name: { - const stringPayload = SessionRequest.methods.signTypedData.getMessageFromData(data) - payload = JSON.stringify(JSON.parse(stringPayload), null, 2) - break - } - case SessionRequest.methods.signTransaction.name: - case SessionRequest.methods.sendTransaction.name: - // For transactions we process the data in a different way as follows - break - default: - console.error("Unhandled method", method) - break; - } - - let value = SQUtils.AmountsArithmetic.fromNumber(0) - if (d.isTransactionMethod(method)) { - let txObj = d.getTxObject(method, data) - let tx = Object.assign({}, txObj) - if (tx.value) { - value = hexToEth(tx.value) - tx.value = value.toString() - } - if (tx.maxFeePerGas) { - tx.maxFeePerGas = hexToGwei(tx.maxFeePerGas).toString() - } - if (tx.maxPriorityFeePerGas) { - tx.maxPriorityFeePerGas = hexToGwei(tx.maxPriorityFeePerGas).toString() - } - if (tx.gasPrice) { - tx.gasPrice = hexToGwei(tx.gasPrice) - } - if (tx.gasLimit) { - tx.gasLimit = parseInt(root.store.hexToDec(tx.gasLimit)) - } - if (tx.nonce) { - tx.nonce = parseInt(root.store.hexToDec(tx.nonce)) - } - - payload = JSON.stringify(tx, null, 2) - } - return { - preparedData: payload, - value: value - } - } - - function hexToEth(value) { - return hexToEthDenomination(value, "eth") - } - function hexToGwei(value) { - return hexToEthDenomination(value, "gwei") - } - function hexToEthDenomination(value, ethUnit) { - let unitMapping = { - "gwei": 9, - "eth": 18 - } - let BigOps = SQUtils.AmountsArithmetic - let decValue = root.store.hexToDec(value) - if (!!decValue) { - return BigOps.div(BigOps.fromNumber(decValue), BigOps.fromNumber(1, unitMapping[ethUnit])) - } - return BigOps.fromNumber(0) - } - - function isTransactionMethod(method) { - return method === SessionRequest.methods.signTransaction.name - || method === SessionRequest.methods.sendTransaction.name - } - - /// Returns null if the account is not found - function lookupAccountFromEvent(event, method) { - var address = "" - if (method === SessionRequest.methods.personalSign.name) { - if (event.params.request.params.length < 2) { - return address - } - address = event.params.request.params[1] - } else if (method === SessionRequest.methods.sign.name) { - if (event.params.request.params.length === 1) { - return address - } - address = event.params.request.params[0] - } else if(method === SessionRequest.methods.signTypedData_v4.name || - method === SessionRequest.methods.signTypedData.name) - { - if (event.params.request.params.length < 2) { - return address - } - address = event.params.request.params[0] - } else if (method === SessionRequest.methods.signTransaction.name - || method === SessionRequest.methods.sendTransaction.name) { - if (event.params.request.params.length == 0) { - return address - } - address = event.params.request.params[0].from - } else { - console.error("Unsupported method to lookup account: ", method) - return null - } - const account = SQUtils.ModelUtils.getFirstModelEntryIf(root.wcService.validAccounts, (account) => { - return account.address.toLowerCase() === address.toLowerCase(); - }) - - if (!account) { - return address - } - - return account.address - } - - /// Returns null if the network is not found - function lookupNetworkFromEvent(event, method) { - if (SessionRequest.getSupportedMethods().includes(method) === false) { - return null - } - const chainId = DAppsHelpers.chainIdFromEip155(event.params.chainId) - const network = SQUtils.ModelUtils.getByKey(root.walletStore.filteredFlatModel, "chainId", chainId) - - if (!network) { - return null - } - - return network.chainId - } - - function extractMethodData(event, method) { - if (method === SessionRequest.methods.personalSign.name || - method === SessionRequest.methods.sign.name) - { - if (event.params.request.params.length < 1) { - return null - } - let message = "" - const messageIndex = (method === SessionRequest.methods.personalSign.name ? 0 : 1) - const messageParam = event.params.request.tx.data - - // There is no standard on how data is encoded. Therefore we support hex or utf8 - if (DAppsHelpers.isHex(messageParam)) { - message = DAppsHelpers.hexToString(messageParam) - } else { - message = messageParam - } - return SessionRequest.methods.personalSign.buildDataObject(message) - } else if (method === SessionRequest.methods.signTypedData_v4.name || - method === SessionRequest.methods.signTypedData.name) - { - if (event.params.request.params.length < 2) { - return null - } - let jsonMessage = event.params.request.params[1] - let methodObj = method === SessionRequest.methods.signTypedData_v4.name - ? SessionRequest.methods.signTypedData_v4 - : SessionRequest.methods.signTypedData - return methodObj.buildDataObject(jsonMessage) - } else if (method === SessionRequest.methods.signTransaction.name) { - if (event.params.request.params.length == 0) { - return null - } - let tx = event.params.request.params[0] - return SessionRequest.methods.signTransaction.buildDataObject(tx) - } else if (method === SessionRequest.methods.sendTransaction.name) { - if (event.params.request.params.length == 0) { - return null - } - const tx = event.params.request.params[0] - return SessionRequest.methods.sendTransaction.buildDataObject(tx) - } else { - return null - } - } - - function executeSessionRequest(request, password, pin) { - if (!SessionRequest.getSupportedMethods().includes(request.method)) { - console.error("Unsupported method to execute: ", request.method) - return - } - - if (password === "") { - console.error("No password provided to sign message") - return - } - - if (request.method === SessionRequest.methods.sign.name) { - store.signMessageUnsafe(request.topic, - request.requestId, - request.accountAddress, - SessionRequest.methods.personalSign.getMessageFromData(request.data), - password, - pin) - } else if (request.method === SessionRequest.methods.personalSign.name) { - store.signMessage(request.topic, - request.requestId, - request.accountAddress, - SessionRequest.methods.personalSign.getMessageFromData(request.data), - password, - pin) - } else if (request.method === SessionRequest.methods.signTypedData_v4.name || - request.method === SessionRequest.methods.signTypedData.name) - { - let legacy = request.method === SessionRequest.methods.signTypedData.name - store.safeSignTypedData(request.topic, - request.requestId, - request.accountAddress, - SessionRequest.methods.signTypedData.getMessageFromData(request.data), - request.chainId, - legacy, - password, - pin) - } else if (request.method === SessionRequest.methods.signTransaction.name) { - let txObj = SessionRequest.methods.signTransaction.getTxObjFromData(request.data) - store.signTransaction(request.topic, - request.requestId, - request.accountAddress, - request.chainId, - txObj, - password, - pin) - } else if (request.method === SessionRequest.methods.sendTransaction.name) { - store.sendTransaction(request.topic, - request.requestId, - request.accountAddress, - request.chainId, - request.data.tx, - password, - pin) - } - } - - function acceptSessionRequest(topic, id, signature) { - console.debug(`Connector DappsConnectorSDK.acceptSessionRequest; requestId: ${root.requestId}, signature: "${signature}"`) - - sessionRequestLoader.active = false - controller.approveTransactionRequest(requestId, signature) - - root.wcService.displayToastMessage(qsTr("Successfully signed transaction from %1").arg(root.dappInfo.url), false) - } - - function getActiveSession(dappInfos) { - let sessionTemplate = (dappUrl, dappName, dappIcon) => { - return { - "peer": { - "metadata": { - "description": "-", - "icons": [ - dappIcon - ], - "name": dappName, - "url": dappUrl - } - }, - "topic": dappUrl - }; - } - - let session = root.wcService.connectorDAppsProvider.getActiveSession(dappInfos.url) - if (!session) { - console.error("Connector.lookupSession: error finding session for requestId ", root.requestId) - - return - } - - return sessionTemplate(session.url, session.name, session.icon) - } - - function authenticate(request) { - return store.authenticateUser(request.topic, request.requestId, request.accountAddress) - } - } - Connections { target: root.store + enabled: root.enabled - function onUserAuthenticated(topic, id, password, pin) { - var request = requests.findRequest(topic, id) - if (request === null) { - console.error(">Error finding event for topic", topic, "id", id) - return - } - d.executeSessionRequest(request, password, pin) - } + function onSignRequested(requestId, dappInfoString) { + try { + var dappInfo = JSON.parse(dappInfoString) + var txArgsParams = JSON.parse(dappInfo.txArgs) + let event = d.buildSessionRequest(requestId, dappInfo.url, dappInfo.chainId, SessionRequest.methods.sendTransaction.name, txArgsParams) - function onUserAuthenticationFailed(topic, id) { - var request = requests.findRequest(topic, id) - let methodStr = SessionRequest.methodToUserString(request.method) - if (request === null || !methodStr) { - return - } - d.lookupSession(topic, function(session) { - if (session === null) - return - root.displayToastMessage(qsTr("Failed to authenticate %1").arg(session.peer.metadata.url), true) - }) - } - - function onSigningResult(topic, id, data) { - let isSuccessful = (data != "") - if (isSuccessful) { - // acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal - d.acceptSessionRequest(topic, id, data) - } else { - console.error("signing error") + root.sessionRequestEvent(event) + } catch (e) { + console.error("Failed to parse dappInfo for session request", e) } } - } - Loader { - id: connectDappLoader - - active: false - - property var dappChains: [] - property var sessionProposal: null - property var availableNamespaces: null - property var sessionTopic: null - readonly property var proposalMedatada: !!sessionProposal - ? sessionProposal.params.proposer.metadata - : { name: "", url: "", icons: [] } - - sourceComponent: ConnectDAppModal { - visible: true - - onClosed: { - rejectSession(root.requestId) - connectDappLoader.active = false - } - flatNetworks: root.walletStore.filteredFlatModel - accounts: root.wcService.validAccounts - - dAppUrl: proposalMedatada.url - dAppName: proposalMedatada.name - dAppIconUrl: !!proposalMedatada.icons && proposalMedatada.icons.length > 0 ? proposalMedatada.icons[0] : "" - multipleChainSelection: false - - onConnect: { - connectDappLoader.active = false - approveSession(root.requestId, selectedAccount.address, selectedChains) - } - - onDecline: { - connectDappLoader.active = false - rejectSession(root.requestId) - } - - onDisconnect: { - connectDappLoader.active = false; - controller.recallDAppPermission(root.dappInfo.url) + function onConnectRequested(requestId, dappInfoString) { + try { + var dappInfo = JSON.parse(dappInfoString) + dappInfo.proposal = d.buildSessionProposal(requestId, dappInfo.url, dappInfo.name, dappInfo.icon, SessionRequest.getSupportedMethods()) + d.sessionRequests.set(requestId, dappInfo) + root.sessionProposal(dappInfo.proposal) + } catch (e) { + console.error("Failed to parse dappInfo for connection request", e) } } - } - Loader { - id: sessionRequestLoader - - active: false - - onLoaded: item.open() - - property SessionRequestResolved request: null - - property var dappInfo: null - - sourceComponent: DAppSignRequestModal { - id: dappRequestModal - objectName: "connectorDappsRequestModal" - - readonly property var account: accountEntry.available ? accountEntry.item : { - "address": "", - "name": "", - "emoji": "", - "colorId": 0 - } - - readonly property var network: networkEntry.available ? networkEntry.item : { - "chainId": 0, - "chainName": "", - "iconUrl": "" - } - - loginType: account.migragedToKeycard ? Constants.LoginType.Keycard : root.loginType - formatBigNumber: (number, symbol, noSymbolOption) => root.wcService.walletRootStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption) - - visible: true - - dappName: request.dappName - dappUrl: request.dappUrl - dappIcon: request.dappIcon - - accountColor: Utils.getColorForId(account.colorId) - accountName: account.name - accountAddress: account.address - accountEmoji: account.emoji - - networkName: network.chainName - networkIconPath: Theme.svg(network.iconUrl) - - fiatFees: request.maxFeesText - cryptoFees: request.maxFeesEthText - estimatedTime: "" - feesLoading: !request.maxFeesText || !request.maxFeesEthText - hasFees: signingTransaction - enoughFundsForTransaction: request.haveEnoughFunds - enoughFundsForFees: request.haveEnoughFunds - - signingTransaction: request.method === SessionRequest.methods.signTransaction.name || request.method === SessionRequest.methods.sendTransaction.name - - requestPayload: { - switch(request.method) { - case SessionRequest.methods.personalSign.name: - return SessionRequest.methods.personalSign.getMessageFromData(request.data) - case SessionRequest.methods.sign.name: { - return SessionRequest.methods.sign.getMessageFromData(request.data) - } - case SessionRequest.methods.signTypedData_v4.name: { - const stringPayload = SessionRequest.methods.signTypedData_v4.getMessageFromData(request.data) - return JSON.stringify(JSON.parse(stringPayload), null, 2) - } - case SessionRequest.methods.signTypedData.name: { - const stringPayload = SessionRequest.methods.signTypedData.getMessageFromData(root.payloadData) - return JSON.stringify(JSON.parse(stringPayload), null, 2) - } - case SessionRequest.methods.signTransaction.name: { - const jsonPayload = SessionRequest.methods.signTransaction.getTxObjFromData(request.data) - return JSON.stringify(jsonPayload, null, 2) - } - case SessionRequest.methods.sendTransaction.name: { - const jsonPayload = SessionRequest.methods.sendTransaction.getTxObjFromData(request.data) - return JSON.stringify(request.data, null, 2) - } - } - } - - onClosed: { - Qt.callLater( () => { - sessionRequestLoader.active = false - }) - } - - onAccepted: { - if (!request) { - console.error("Error signing: request is null") - return - } - - d.authenticate(request) - } - - onRejected: { - sessionRequestLoader.active = false - controller.rejectTransactionSigning(root.requestId) - root.wcService.displayToastMessage(qsTr("Failed to sign transaction from %1").arg(request.dappUrl), true) - } - - ModelEntry { - id: networkEntry - sourceModel: root.wcService.flatNetworks - key: "chainId" - value: request.chainId - } - - ModelEntry { - id: accountEntry - sourceModel: root.wcService.validAccounts - key: "address" - value: request.accountAddress + function onDisconnected(dappInfoString) { + try { + let dappItem = JSON.parse(dappInfoString) + root.sessionDelete(dappItem.url, false) + } catch (e) { + console.error("Failed to parse dappInfo for disconnection", e) } } - } - Component { - id: sessionRequestComponent - - SessionRequestResolved { - sourceId: Constants.DAppConnectors.StatusConnect - } - } - - SessionRequestsModel { - id: requests - } - - Connections { - target: root.wcService - - function onRevokeSession(topic) { - if (!topic) { - console.warn(invalidDAppTopicError) - return + function onApproveTransactionResponse(topic, requestId, error) { + try { + const errorStr = error ? "Faled to approve trasnsaction" : "" + root.sessionRequestUserAnswerResult(topic, requestId, true, errorStr) + } catch (e) { + console.error("Failed to approve transaction response", e) } - - controller.recallDAppPermission(topic) - root.wcService.connectorDAppsProvider.revokeSession(topic) - } - } - - Connections { - target: controller - - onDappValidatesTransaction: function(requestId, dappInfoString) { - var dappInfo = JSON.parse(dappInfoString) - root.dappInfo = dappInfo - var txArgsParams = JSON.parse(dappInfo.txArgs) - root.txArgs = txArgsParams - let event = { - "id": root.requestId, - "topic": dappInfo.url, - "params": { - "chainId": `eip155:${dappInfo.chainId}`, - "request": { - "method": SessionRequest.methods.sendTransaction.name, - "params": [ - { - "from": txArgsParams.from, - "to": txArgsParams.to, - "value": txArgsParams.value, - "gasLimit": txArgsParams.gas, - "gasPrice": txArgsParams.gasPrice, - "maxFeePerGas": txArgsParams.maxFeePerGas, - "maxPriorityFeePerGas": txArgsParams.maxPriorityFeePerGas, - "nonce": txArgsParams.nonce, - "data": txArgsParams.data - } - ] - } - } - } - - d.sessionRequestEvent(event) - - sessionRequestLoader.active = true - root.requestId = requestId } - onDappRequestsToConnect: function(requestId, dappInfoString) { - var dappInfo = JSON.parse(dappInfoString) - root.dappInfo = dappInfo - let sessionProposal = { - "params": { - "optionalNamespaces": {}, - "proposer": { - "metadata": { - "description": "-", - "icons": [ - dappInfo.icon - ], - "name": dappInfo.name, - "url": dappInfo.url - } - }, - "requiredNamespaces": { - "eip155": { - "chains": [ - `eip155:${dappInfo.chainId}` - ], - "events": [], - "methods": [SessionRequest.methods.personalSign.name] - } - } - } - }; - - connectDappLoader.sessionProposal = sessionProposal - connectDappLoader.active = true - root.requestId = requestId - } - - onDappGrantDAppPermission: function(dappInfoString) { - let dappItem = JSON.parse(dappInfoString) - const { url, name, icon: iconUrl } = dappItem - - if (!url) { - console.warn(invalidDAppUrlError) - return + function onRejectTransactionResponse(topic, requestId, error) { + try { + const errorStr = error ? "Faled to reject trasnsaction" : "" + root.sessionRequestUserAnswerResult(topic, requestId, false, errorStr) + } catch (e) { + console.error("Failed to reject transaction response", e) } - - root.wcService.connectorDAppsProvider.addSession(url, name, iconUrl) - } - - onDappRevokeDAppPermission: function(dappInfoString) { - let dappItem = JSON.parse(dappInfoString) - let session = { - "url": dappItem.url, - "name": dappItem.name, - "iconUrl": dappItem.icon, - "topic": dappItem.url - } - - if (!session.url) { - console.warn(invalidDAppUrlError) - return - } - root.wcService.connectorDAppsProvider.revokeSession(JSON.stringify(session)) - root.wcService.displayToastMessage(qsTr("Disconnected from %1").arg(dappItem.url), false) } } approveSession: function(requestId, account, selectedChains) { - controller.approveDappConnectRequest(requestId, account, JSON.stringify(selectedChains)) - const { url, name, icon: iconUrl } = root.dappInfo; - //TODO: temporary solution until we have a proper way to handle accounts - //The dappProvider should add a new session only when the backend has validated the connection - //Currently the dapp info is limited to the url, name and icon - root.wcService.connectorDAppsProvider.addSession(url, name, iconUrl, account) - root.wcService.displayToastMessage(qsTr("Successfully authenticated %1").arg(url), false); + try { + if (!d.sessionRequests.has(requestId)) { + console.error("Session request not found") + return + } + const dappInfo = d.sessionRequests.get(requestId) + root.store.approveConnection(requestId, account, JSON.stringify(selectedChains)) + const newSession = d.buildSession(dappInfo.url, dappInfo.name, dappInfo.icon, requestId, account, selectedChains) + root.approveSessionResult(requestId, newSession, "") + d.sessionRequests.delete(requestId) + } catch (e) { + console.error("Failed to approve session", e) + } } rejectSession: function(requestId) { - controller.rejectDappConnectRequest(requestId) - root.wcService.displayToastMessage(qsTr("Failed to authenticate %1").arg(root.dappInfo.url), true) + try { + root.store.rejectConnection(requestId) + root.rejectSessionResult(requestId, "") + d.sessionRequests.delete(requestId) + } catch (e) { + console.error("Failed to reject session", e) + } + } + + acceptSessionRequest: function(topic, requestId, signature) { + root.store.approveTransaction(topic, requestId, signature) + } + + rejectSessionRequest: function(topic, requestId, error) { + root.store.rejectTransaction(topic, requestId, error) + } + + disconnectSession: function(topic) { + root.store.disconnect(topic) + } + + getActiveSessions: function(callback) { + try { + const dappsStr = root.store.getDApps() + const dapps = JSON.parse(dappsStr) + let activeSessions = {} + for (let i = 0; i < dapps.length; i++) { + const dapp = dapps[i] + activeSessions[dapp.url] = d.buildSession(dapp.url, dapp.name, dapp.iconUrl, "", dapp.sharedAccount, [dapp.chainId]) + } + callback(activeSessions) + } catch (e) { + console.error("Failed to get active sessions", e) + callback([]) + } } // We don't expect requests for these. They are here only to spot errors @@ -795,4 +148,86 @@ WalletConnectSDKBase { getPairings: function(callback) { console.error("ConnectorSDK.getPairings: not implemented") } disconnectPairing: function(topic) { console.error("ConnectorSDK.disconnectPairing: not implemented") } buildApprovedNamespaces: function(params, supportedNamespaces) { console.error("ConnectorSDK.buildApprovedNamespaces: not implemented") } + + QtObject { + id: d + readonly property var sessionRequests: new Map() + function buildSession(dappUrl, dappName, dappIcon, proposalId, account, chains) { + let sessionTemplate = (dappUrl, dappName, dappIcon, proposalId, eipAccount, eipChains) => { + return { + peer: { + metadata: { + description: "-", + icons: [ + dappIcon + ], + name: dappName, + url: dappUrl + } + }, + namespaces: { + eip155: { + accounts: [eipAccount], + chains: eipChains + } + }, + pairingTopic: proposalId, + topic: dappUrl + }; + } + + const eipAccount = account ? `eip155:${account}` : "" + const eipChains = chains ? chains.map((chain) => `eip155:${chain}`) : [] + + return sessionTemplate(dappUrl, dappName, dappIcon, proposalId, eipAccount, eipChains) + } + + function buildSessionRequest(requestId, topic, chainId, method, txArgs) { + return { + id: requestId, + topic, + params: { + chainId: `eip155:${chainId}`, + request: { + method: SessionRequest.methods.sendTransaction.name, + params: [ + { + from: txArgs.from, + to: txArgs.to, + value: txArgs.value, + gasLimit: txArgs.gas, + gasPrice: txArgs.gasPrice, + maxFeePerGas: txArgs.maxFeePerGas, + maxPriorityFeePerGas: txArgs.maxPriorityFeePerGas, + nonce: txArgs.nonce, + data: txArgs.data + } + ] + } + } + } + } + + function buildSessionProposal(id, url, name, icon, methods) { + const supportedNamespaces = DAppsHelpers.buildSupportedNamespacesFromModels(root.networksModel, root.accountsModel, methods) + const proposal = { + id, + params: { + optionalNamespaces: {}, + proposer: { + metadata: { + description: "-", + icons: [ + icon + ], + name, + url + } + }, + requiredNamespaces: supportedNamespaces + } + } + return proposal + } + } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WCDappsProvider.qml b/ui/app/AppLayouts/Wallet/services/dapps/WCDappsProvider.qml index d7d35b5258..114ca63433 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WCDappsProvider.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WCDappsProvider.qml @@ -1,7 +1,7 @@ import QtQuick 2.15 import StatusQ 0.1 -import StatusQ.Core.Utils 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils import AppLayouts.Wallet.services.dapps 1.0 @@ -19,7 +19,50 @@ DAppsModel { readonly property int connectorId: Constants.WalletConnect - property bool enabled: true + readonly property bool enabled: sdk.enabled + + signal disconnected(string topic, string dAppUrl) + signal connected(string proposalId, string topic, string dAppUrl) + + Connections { + target: root.sdk + enabled: root.enabled + + function onSessionDelete(topic, err) { + const dapp = root.getByTopic(topic) + if (!dapp) { + console.warn("DApp not found for topic - cannot delete session", topic) + return + } + + root.store.deactivateWalletConnectSession(topic) + d.updateDappsModel() + root.disconnected(topic, dapp.url) + } + function onSdkInit(success, result) { + if (!success) { + return + } + d.updateDappsModel() + } + function onApproveSessionResult(proposalId, session, error) { + if (error) { + return + } + + root.store.addWalletConnectSession(JSON.stringify(session)) + d.updateDappsModel() + root.connected(proposalId, session.topic, session.peer.metadata.url) + } + + function onAcceptSessionAuthenticateResult(id, result, error) { + if (error) { + return + } + d.updateDappsModel() + root.connected(id, result.topic, result.session.peer.metadata.url) + } + } Component.onCompleted: { if (!enabled) { @@ -37,34 +80,9 @@ DAppsModel { } } - QObject { + SQUtils.QObject { id: d - property Connections sdkConnections: Connections { - target: root.sdk - enabled: root.enabled - - function onSessionDelete(topic, err) { - d.updateDappsModel() - } - function onSdkInit(success, result) { - if (success) { - d.updateDappsModel() - } - } - function onApproveSessionResult(topic, success, result) { - if (success) { - d.updateDappsModel() - } - } - - function onAcceptSessionAuthenticateResult(id, result, error) { - if (!error) { - d.updateDappsModel() - } - } - } - property var dappsListReceivedFn: null property var getActiveSessionsFn: null function updateDappsModel() @@ -82,7 +100,7 @@ DAppsModel { name: cachedEntry.name, iconUrl: cachedEntry.iconUrl, accountAddresses: [], - topic: "", + topic: cachedEntry.url, connectorId: root.connectorId, rawSessions: [] } @@ -99,12 +117,16 @@ DAppsModel { getActiveSessionsFn = () => { sdk.getActiveSessions((allSessionsAllProfiles) => { + if (!allSessionsAllProfiles) { + console.warn("Failed to get active sessions") + return + } + root.store.dappsListReceived.disconnect(dappsListReceivedFn); const dAppsMap = {} const topics = [] - const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(allSessionsAllProfiles, supportedAccountsModel) - + const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(allSessionsAllProfiles, root.supportedAccountsModel) for (const sessionID in sessions) { const session = sessions[sessionID] const dapp = session.peer.metadata @@ -120,7 +142,7 @@ DAppsModel { // more modern syntax (ES-6) is not available yet const combinedAddresses = new Set(existingDApp.accountAddresses.concat(accounts)); existingDApp.accountAddresses = Array.from(combinedAddresses); - dapp.rawSessions = [...existingDApp.rawSessions, session] + existingDApp.rawSessions = [...existingDApp.rawSessions, session] } else { dapp.accountAddresses = accounts dapp.topic = sessionID diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml index ee46359e56..e2f11811b2 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml @@ -13,10 +13,8 @@ import "types" WalletConnectSDKBase { id: root - readonly property alias sdkReady: d.sdkReady + readonly property bool sdkReady: d.sdkReady - // Enable the WalletConnect SDK - property alias enableSdk: loader.active property alias userUID: loader.profileName readonly property alias url: loader.url @@ -509,7 +507,7 @@ WalletConnectSDKBase { } function onRejectSessionAuthenticateResult(id, result, error) { - //console.debug(`WC WalletConnectSDK.onRejectSessionAuthenticateResult; id: ${id}, result: ${result}, error: ${error}`) + console.debug(`WC WalletConnectSDK.onRejectSessionAuthenticateResult; id: ${id}, result: ${result}, error: ${error}`) root.rejectSessionAuthenticateResult(id, result, error) } } @@ -518,7 +516,7 @@ WalletConnectSDKBase { id: loader anchors.fill: parent - + active: root.enabled url: "qrc:/app/AppLayouts/Wallet/services/dapps/sdk/src/index.html" webChannelObjects: [ statusObject ] @@ -528,5 +526,11 @@ WalletConnectSDKBase { onPageLoadingError: function(error) { console.error("WebEngineLoader.onPageLoadingError: ", error) } + + onActiveChanged: function() { + if (!active) { + d.sdkReady = false + } + } } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml index 3866f204b7..518ab91e97 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml @@ -3,24 +3,25 @@ import QtQuick 2.15 /// SDK requires a visible parent to embed WebEngineView Item { required property string projectId + property bool enabled: true signal statusChanged(string message) signal sdkInit(bool success, var result) signal pairResponse(bool success) signal sessionProposal(var sessionProposal) signal sessionProposalExpired() - signal buildApprovedNamespacesResult(var id, var session, string error) - signal approveSessionResult(var proposalId, var approvedNamespaces, string error) - signal rejectSessionResult(var proposalId, string error) - signal sessionRequestExpired(var id) + signal buildApprovedNamespacesResult(string id, var session, string error) + signal approveSessionResult(string proposalId, var approvedNamespaces, string error) + signal rejectSessionResult(string proposalId, string error) + signal sessionRequestExpired(string id) signal sessionRequestEvent(var sessionRequest) signal sessionRequestUserAnswerResult(string topic, string id, bool accept /* not reject */, string error) signal sessionAuthenticateRequest(var sessionData) - signal populateAuthPayloadResult(var id, var authPayload, string error) - signal formatAuthMessageResult(var id, var request, string error) - signal acceptSessionAuthenticateResult(var id, var result, string error) - signal rejectSessionAuthenticateResult(var id, var result, string error) - signal buildAuthObjectResult(var id, var authObject, string error) + signal populateAuthPayloadResult(string id, var authPayload, string error) + signal formatAuthMessageResult(string id, var request, string error) + signal acceptSessionAuthenticateResult(string id, var result, string error) + signal rejectSessionAuthenticateResult(string id, var result, string error) + signal buildAuthObjectResult(string id, var authObject, string error) signal sessionDelete(var topic, string error) diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/DAppConnectionsPlugin.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/DAppConnectionsPlugin.qml new file mode 100644 index 0000000000..9da07c5066 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/DAppConnectionsPlugin.qml @@ -0,0 +1,353 @@ +import QtQuick 2.15 + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils + +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 + +import shared.stores 1.0 +import utils 1.0 + +// Plugin providing the connection handling for dApps +// Features provided: +// 1. connect +// 2. disconnect +// 3. active connections model +SQUtils.QObject { + id: root + + required property WalletConnectSDKBase wcSDK + required property WalletConnectSDKBase bcSDK + required property DAppsStore dappsStore + // Required roles: address + required property var accountsModel + // Required roles: chainId + required property var networksModel + + // Output model with the following roles: + // - name: string (optional) + // - url: string (required) + // - iconUrl: string (optional) + // - topic: string (required) + // - connectorId: int (required) + // - accountAddressses: [{address: string}] (required) + // - chains: string (optional) + // - rawSessions: [{session: object}] (optional) + readonly property ConcatModel dappsModel: dappsModel + + // Output signal when a dApp is disconnected + signal disconnected(string topic, string dAppUrl) + // Output signal when a new connection is proposed + signal connected(string proposalId, string topic, string dAppUrl, int connectorId) + // Output signal when a new connection is proposed by the SDK + signal newConnectionProposed(string key, var chains, string dAppUrl, string dAppName, string dAppIcon) + // Output signal when a new connection is failed + signal newConnectionFailed(string key, string dappUrl, int errorCode) + + // Request to disconnect a dApp identified by the topic + function disconnect(topic) { + d.disconnect(topic) + } + + // Request to connect a dApp identified by the proposal key + // chains: array of chainIds + // accountAddress: string + function connect(key, chains, accoutAddress) { + d.connect(key, chains, accoutAddress) + } + + // Request to reject a dApp connection request identified by the proposal key + function reject(key) { + d.reject(key) + } + + WCDappsProvider { + id: dappsProvider + sdk: root.wcSDK + store: root.dappsStore + supportedAccountsModel: root.accountsModel + onConnected: (proposalId, topic, dappUrl) => { + root.connected(proposalId, topic, dappUrl, Constants.WalletConnect) + } + onDisconnected: (topic, dappUrl) => { + root.disconnected(topic, dappUrl) + } + } + + BCDappsProvider { + id: connectorDAppsProvider + bcSDK: root.bcSDK + onConnected: (pairingId, topic, dappUrl) => { + root.connected(pairingId, topic, dappUrl, Constants.StatusConnect) + } + onDisconnected: (topic, dappUrl) => { + root.disconnected(topic, dappUrl) + } + } + + ConcatModel { + id: dappsModel + markerRoleName: "source" + + sources: [ + SourceModel { + model: dappsProvider.model + markerRoleValue: "walletConnect" + }, + SourceModel { + model: connectorDAppsProvider.model + markerRoleValue: "statusConnect" + } + ] + } + + // These two objects don't share a common interface because Qt5.15.2 would freeze for some reason + QtObject { + id: bcConnectionPromise + function resolve(context, key, approvedChainIds, accountAddress) { + root.bcSDK.approveSession(key, accountAddress, approvedChainIds) + } + function reject(context, key) { + root.bcSDK.rejectSession(key) + } + } + + QtObject { + id: wcConnectionPromise + function resolve(context, key, approvedChainIds, accountAddress) { + const approvedNamespaces = JSON.parse( + DAppsHelpers.buildSupportedNamespaces(approvedChainIds, + [accountAddress], + SessionRequest.getSupportedMethods())) + d.acceptedSessionProposal = context + d.acceptedNamespaces = approvedNamespaces + + root.wcSDK.buildApprovedNamespaces(key, context.params, approvedNamespaces) + } + function reject(context, key) { + root.wcSDK.rejectSession(key) + } + } + + // Flow for BrowserConnect + // 1. onSessionProposal -> new connection proposal received + // 3. onApproveSessionResult -> session approve result + // 4. onRejectSessionResult -> session reject result + Connections { + target: root.bcSDK + + function onSessionProposal(sessionProposal) { + const key = sessionProposal.id + d.activeProposals.set(key.toString(), { context: sessionProposal, promise: bcConnectionPromise }) + root.newConnectionProposed(key, [1], sessionProposal.params.proposer.metadata.url, sessionProposal.params.proposer.metadata.name, sessionProposal.params.proposer.metadata.icons[0]) + } + + function onApproveSessionResult(proposalId, session, err) { + if (!d.activeProposals.has(proposalId.toString())) { + console.error("No active proposal found for key: " + proposalId) + return + } + const dappUrl = d.activeProposals.get(proposalId.toString()).context.params.proposer.metadata.url + d.activeProposals.delete(proposalId.toString()) + if (err) { + root.newConnectionFailed(proposalId, dappUrl, Pairing.errors.unknownError) + return + } + } + + function onRejectSessionResult(proposalId, err) { + if (!d.activeProposals.has(proposalId.toString())) { + console.error("No active proposal found for key: " + proposalId) + return + } + const dappUrl = d.activeProposals.get(proposalId.toString()).context.params.proposer.metadata.url + d.activeProposals.delete(proposalId.toString()) + if (err) { + root.newConnectionFailed(proposalId, dappUrl, Pairing.errors.rejectFailed) + return + } + root.newConnectionFailed(proposalId, dappUrl, Pairing.errors.userRejected) + } + } + + // Flow for WalletConnect + // 1. onSessionProposal -> new connection proposal received + // 2. onBuildApprovedNamespacesResult -> get the supported namespaces to be sent for approval + // 3. onApproveSessionResult -> session approve result + // 4. onRejectSessionResult -> session reject result + Connections { + target: root.wcSDK + + function onSessionProposal(sessionProposal) { + const key = sessionProposal.id + d.activeProposals.set(key.toString(), { context: sessionProposal, promise: wcConnectionPromise }) + const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels( + root.networksModel, root.accountsModel, SessionRequest.getSupportedMethods()) + root.wcSDK.buildApprovedNamespaces(key, sessionProposal.params, JSON.parse(supportedNamespacesStr)) + } + + function onBuildApprovedNamespacesResult(key, approvedNamespaces, error) { + if (!d.activeProposals.has(key.toString())) { + console.error("No active proposal found for key: " + key) + return + } + const proposal = d.activeProposals.get(key.toString()).context + const dAppUrl = proposal.params.proposer.metadata.url + + if(error || !approvedNamespaces) { + // Check that it contains Non conforming namespaces" + if (error.includes("Non conforming namespaces")) { + root.newConnectionFailed(proposal.id, dAppUrl, Pairing.errors.unsupportedNetwork) + } else { + root.newConnectionFailed(proposal.id, dAppUrl, Pairing.errors.unknownError) + } + return + } + + approvedNamespaces = d.applyChainAgnosticFix(approvedNamespaces) + if (d.acceptedSessionProposal) { + root.wcSDK.approveSession(d.acceptedSessionProposal, approvedNamespaces) + } else { + const res = DAppsHelpers.extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces) + const chains = res.chains + const dAppName = proposal.params.proposer.metadata.name + const dAppIcons = proposal.params.proposer.metadata.icons + const dAppIcon = dAppIcons && dAppIcons.length > 0 ? dAppIcons[0] : "" + + root.newConnectionProposed(key, chains, dAppUrl, dAppName, dAppIcon) + } + } + + function onApproveSessionResult(proposalId, session, err) { + if (!d.activeProposals.has(proposalId.toString())) { + console.error("No active proposal found for key: " + proposalId) + return + } + const dappUrl = d.activeProposals.get(proposalId.toString()) + .context.params.proposer.metadata.url + d.activeProposals.delete(proposalId.toString()) + d.acceptedSessionProposal = null + d.acceptedNamespaces = null + if (err) { + root.newConnectionFailed(proposalId, dappUrl, Pairing.errors.unknownError) + return + } + } + + function onRejectSessionResult(proposalId, err) { + if (!d.activeProposals.has(proposalId.toString())) { + console.error("No active proposal found for key: " + proposalId) + return + } + const dappUrl = d.activeProposals.get(proposalId.toString()) + .context.params.proposer.metadata.url + d.activeProposals.delete(proposalId.toString()) + if (err) { + root.newConnectionFailed(proposalId, dappUrl, Pairing.errors.rejectFailed) + return + } + root.newConnectionFailed(proposalId, dappUrl, Pairing.errors.userRejected) + } + } + + QtObject { + id: d + + property var activeProposals: new Map() + property var acceptedSessionProposal: null + property var acceptedNamespaces: null + + function disconnect(connectionId) { + const dApp = d.getDAppByTopic(connectionId) + if (!dApp) { + console.error("Disconnecting dApp: dApp not found") + return + } + if (!dApp.connectorId == undefined) { + console.error("Disconnecting dApp: connectorId not found") + return + } + + const sdk = dApp.connectorId === Constants.WalletConnect ? root.wcSDK : root.bcSDK + sdkDisconnect(dApp, sdk) + } + + // Disconnect all sessions for a dApp + function sdkDisconnect(dapp, sdk) { + SQUtils.ModelUtils.forEach(dapp.rawSessions, (session) => { + sdk.disconnectSession(session.topic) + }) + } + + function reject(key) { + if (!d.activeProposals.has(key.toString())) { + console.error("Rejecting dApp: dApp not found") + return + } + + const proposal = d.activeProposals.get(key.toString()) + proposal.promise.reject(proposal.context, key) + } + + function connect(key, chains, accoutAddress) { + if (!d.activeProposals.has(key.toString())) { + console.error("Connecting dApp: dApp not found", key) + return + } + + const proposal = d.activeProposals.get(key.toString()) + proposal.promise.resolve(proposal.context, key, chains, accoutAddress) + } + + function getDAppByTopic(topic) { + return SQUtils.ModelUtils.getFirstModelEntryIf(dappsModel, (modelItem) => { + if (modelItem.topic == topic) { + return true + } + if (!modelItem.rawSessions) { + return false + } + for (let i = 0; i < modelItem.rawSessions.ModelCount.count; i++) { + if (modelItem.rawSessions.get(i).topic == topic) { + return true + } + } + }) + } + + //Special case for chain agnostic dapps + //WC considers the approved namespace as valid, but there's no chainId or account established + //Usually this request is declared by using `eip155:0`, but we don't support this chainID, resulting in empty `chains` and `accounts` + //The established connection will use for all user approved chains and accounts + //This fix is applied to all valid namespaces that don't have a chainId or account + function applyChainAgnosticFix(approvedNamespaces) { + try { + const an = approvedNamespaces.eip155 + const chainAgnosticRequest = (!an.chains || an.chains.length === 0) && (!an.accounts || an.accounts.length === 0) + if (!chainAgnosticRequest) { + return approvedNamespaces + } + + // If the `d.acceptedNamespaces` is set it means the user already confirmed the chain and account + if (!!d.acceptedNamespaces) { + approvedNamespaces.eip155.chains = d.acceptedNamespaces.eip155.chains + approvedNamespaces.eip155.accounts = d.acceptedNamespaces.eip155.accounts + return approvedNamespaces + } + + // Show to the user all possible chains + const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels( + root.networksModel, root.accountsModel, SessionRequest.getSupportedMethods()) + const supportedNamespaces = JSON.parse(supportedNamespacesStr) + + approvedNamespaces.eip155.chains = supportedNamespaces.eip155.chains + approvedNamespaces.eip155.accounts = supportedNamespaces.eip155.accounts + } catch (e) { + console.warn("WC Error applying chain agnostic fix", e) + } + + return approvedNamespaces + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SignRequestPlugin.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SignRequestPlugin.qml new file mode 100644 index 0000000000..1b934185a5 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SignRequestPlugin.qml @@ -0,0 +1,671 @@ +import QtQuick 2.15 +import QtQml 2.15 + +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils + +import shared.stores 1.0 +import utils 1.0 + +/// Plugin that listens for session requests and manages the lifecycle of the request. +SQUtils.QObject { + id: root + + required property WalletConnectSDKBase sdk + required property DAppsStore store + /// Expected to have the following roles: + /// - topic + /// - name + /// - url + /// - iconUrl + /// - rawSessions + required property var dappsModel + /// Expected to have the following roles: + /// - tokensKey + /// - balances + required property var groupedAccountAssetsModel + /// Expected to have the following roles: + /// - layer + /// - chainId + required property var networksModel + /// Expected to have the following roles: + /// - address + required property var accountsModel + /// App currency + required property string currentCurrency + // SessionRequestsModel where the requests are stored + // This component will append and remove requests from this model + required property SessionRequestsModel requests + // Function to transform the eth value to fiat + property var getFiatValue: (maxFeesEthStr, token /*Constants.ethToken*/) => console.error("getFiatValue not implemented") + + // Signals + /// Signal emitted when a session request is accepted + signal accepted(string topic, string id, string data) + /// Signal emitted when a session request is rejected + signal rejected(string topic, string id, bool hasError) + /// Signal emitted when a session request is completed + /// Completed mean that we have the ACK from the SDK + signal signCompleted(string topic, string id, bool userAccepted, string error) + + function requestReceived(event, dappName, dappUrl, dappIcon, connectorId) { + d.onSessionRequestEvent(event, dappName, dappUrl, dappIcon, connectorId) + } + + function requestResolved(topic, id) { + root.requests.removeRequest(topic, id) + } + + function requestExpired(sessionId) { + d.onSessionRequestExpired(sessionId) + } + + onRejected: (topic, id, hasError) => { + root.sdk.rejectSessionRequest(topic, id, hasError) + } + + onAccepted: (topic, id, data) => { + root.sdk.acceptSessionRequest(topic, id, data) + } + + Component { + id: sessionRequestComponent + + SessionRequestWithAuth { + id: request + store: root.store + + function signedHandler(topic, id, data) { + if (topic != request.topic || id != request.requestId) { + return + } + root.store.signingResult.disconnect(request.signedHandler) + + let hasErrors = (data == "") + if (!hasErrors) { + root.accepted(topic, id, data) + } else { + request.reject(true) + } + } + + onActiveChanged: { + if (active === false) { + d.unsubscribeForFeeUpdates(request.topic, request.requestId) + } + if (active === true) { + d.subscribeForFeeUpdates(request.topic, request.requestId) + } + } + + onRejected: (hasError) => { + root.rejected(request.topic, request.requestId, hasError) + d.unsubscribeForFeeUpdates(request.topic, request.requestId) + } + + onAuthFailed: () => { + root.rejected(request.topic, request.requestId, true /*hasError*/) + d.unsubscribeForFeeUpdates(request.topic, request.requestId) + } + + onExecute: (password, pin) => { + d.unsubscribeForFeeUpdates(request.topic, request.requestId) + root.store.signingResult.connect(request.signedHandler) + let executed = false + try { + executed = d.executeSessionRequest(request, password, pin, request.feesInfo) + } catch (e) { + console.error("Error executing session request", e) + } + + if (!executed) { + root.rejected(request.topic, request.requestId, true /*hasError*/) + root.store.signingResult.disconnect(request.signedHandler) + } + } + } + } + + Connections { + target: root.sdk + + function onSessionRequestEvent(sessionRequest) { + const { id, topic } = sessionRequest + const dapp = SQUtils.ModelUtils.getFirstModelEntryIf(root.dappsModel, (dapp) => { + if (dapp.topic === topic) { + return true + } + return !!SQUtils.ModelUtils.getFirstModelEntryIf(dapp.rawSessions, (session) => { + if (session.topic === topic) { + return true + } + }) + }) + + if (!dapp) { + console.warn("Error finding dapp for topic", topic, "id", id) + root.sdk.rejectSessionRequest(topic, id, true) + return + } + + root.requestReceived(sessionRequest, dapp.name, dapp.url, dapp.iconUrl, dapp.connectorId) + } + + function onSessionRequestExpired(sessionId) { + root.requestExpired(sessionId) + } + + function onSessionRequestUserAnswerResult(topic, id, accept, error) { + let request = root.requests.findRequest(topic, id) + + if (request === null) { + console.error("Error finding event for topic", topic, "id", id) + return + } + Qt.callLater(() => root.requestResolved(topic, id)) + + if (error) { + root.signCompleted(topic, id, accept, error) + console.error(`Error accepting session request for topic: ${topic}, id: ${id}, accept: ${accept}, error: ${error}`) + return + } + + root.signCompleted(topic, id, accept, "") + } + } + + QtObject { + id: d + + function onSessionRequestEvent(event, dappName, dappUrl, dappIcon, connectorId) { + try { + const res = d.resolve(event, dappName, dappUrl, dappIcon, connectorId) + if (res.conde === SessionRequest.Ignored) { + return + } + if (res.code !== SessionRequest.NoError) { + root.rejected(event.topic, event.id, true) + return + } + root.requests.enqueue(res.obj) + } catch (e) { + console.error("Error processing session request event", e) + root.rejected(event.topic, event.id, true) + } + } + + function onSessionRequestExpired(sessionId) { + // Expired event coming from WC + // Handling as a failsafe in case the event is not processed by the SDK + let request = root.requests.findById(sessionId) + if (request === null) { + console.error("Error finding event for session id", sessionId) + return + } + + if (request.isExpired()) { + return //nothing to do. The request is already expired + } + + request.setExpired() + d.unsubscribeForFeeUpdates(request.topic, request.requestId) + } + // returns { + // obj: obj or nil + // code: SessionRequest.ErrorCode + // } + function resolve(event, dappName, dappUrl, dappIcon, connectorId) { + const {request, error} = SessionRequestResolver.resolveEvent(event, root.accountsModel, root.networksModel, root.store.hexToDec) + if (error !== SessionRequest.NoError) { + return { obj: null, code: error } + } + if (!request) { + return { obj: null, code: SessionRequest.RuntimeError } + } + const mainNet = lookupMainnetNetwork() + if (!mainNet) { + console.error("Mainnet network not found") + return { obj: null, code: SessionRequest.RuntimeError } + } + + updateFeesOnPreparedData(request) + + let obj = sessionRequestComponent.createObject(null, { + event: request.event, + topic: request.topic, + requestId: request.requestId, + method: request.method, + accountAddress: request.account, + chainId: request.chainId, + data: request.data, + preparedData: JSON.stringify(request.preparedData), + expirationTimestamp: request.expiryTimestamp, + dappName, + dappUrl, + dappIcon, + sourceId: connectorId, + value: request.value + }) + if (obj === null) { + console.error("Error creating SessionRequestResolved for event") + return { obj: null, code: SessionRequest.RuntimeError } + } + + if (!request.transaction) { + obj.haveEnoughFunds = true + return { obj: obj, code: SessionRequest.NoError } + } + + updateFeesParamsToPassedObj(obj) + + return { + obj: obj, + code: SessionRequest.NoError + } + } + + + // Updates the fees to a SessionRequestResolved + function updateFeesParamsToPassedObj(requestItem) { + if (!(requestItem instanceof SessionRequestResolved)) { + return + } + if (!SessionRequest.isTransactionMethod(requestItem.method)) { + return + } + + const mainNet = lookupMainnetNetwork() + if (!mainNet) { + console.error("Mainnet network not found") + return { obj: null, code: SessionRequest.RuntimeError } + } + + const tx = SessionRequest.getTxObject(requestItem.method, requestItem.data) + requestItem.estimatedTimeCategory = root.store.getEstimatedTime(requestItem.chainId, tx.maxFeePerGas || tx.gasPrice || "") + + let st = getEstimatedFeesStatus(tx, requestItem.method, requestItem.chainId, mainNet.chainId) + let fundsStatus = checkFundsStatus(st.feesInfo.maxFees, st.feesInfo.l1GasFee, requestItem.accountAddress, requestItem.chainId, mainNet.chainId, requestItem.value) + requestItem.fiatMaxFees = st.fiatMaxFees + requestItem.ethMaxFees = st.maxFeesEth + requestItem.haveEnoughFunds = fundsStatus.haveEnoughFunds + requestItem.haveEnoughFees = fundsStatus.haveEnoughForFees + requestItem.feesInfo = st.feesInfo + } + + // Updates the fee in the transaction preview on a JS Object built by SessionRequest + function updateFeesOnPreparedData(request) { + if (!request.transaction && !request.preparedData instanceof Object) { + return + } + + let fees = root.store.getSuggestedFees(request.chainId) + if (!request.preparedData.maxFeePerGas + && request.preparedData.hasOwnProperty("maxFeePerGas") + && fees.eip1559Enabled) { + request.preparedData.maxFeePerGas = d.getFeesForFeesMode(fees) + } + + if (!request.preparedData.maxPriorityFeePerGas + && request.preparedData.hasOwnProperty("maxPriorityFeePerGas") + && fees.eip1559Enabled) { + request.preparedData.maxPriorityFeePerGas = fees.maxPriorityFeePerGas + } + + if (!request.preparedData.gasPrice + && request.preparedData.hasOwnProperty("gasPrice") + && !fees.eip1559Enabled) { + request.preparedData.gasPrice = fees.gasPrice + } + } + + /// Returns null if the network is not found + function lookupMainnetNetwork() { + return SQUtils.ModelUtils.getByKey(root.networksModel, "layer", 1) + } + + function executeSessionRequest(request, password, pin, payload) { + if (!SessionRequest.getSupportedMethods().includes(request.method)) { + console.error("Unsupported method to execute: ", request.method) + return false + } + + if (password === "") { + console.error("No password provided to sign message") + return false + } + + if (request.method === SessionRequest.methods.sign.name) { + root.store.signMessageUnsafe(request.topic, + request.requestId, + request.accountAddress, + SessionRequest.methods.personalSign.getMessageFromData(request.data), + password, + pin) + } else if (request.method === SessionRequest.methods.personalSign.name) { + root.store.signMessage(request.topic, + request.requestId, + request.accountAddress, + SessionRequest.methods.personalSign.getMessageFromData(request.data), + password, + pin) + } else if (request.method === SessionRequest.methods.signTypedData_v4.name || + request.method === SessionRequest.methods.signTypedData.name) + { + let legacy = request.method === SessionRequest.methods.signTypedData.name + root.store.safeSignTypedData(request.topic, + request.requestId, + request.accountAddress, + SessionRequest.methods.signTypedData.getMessageFromData(request.data), + request.chainId, + legacy, + password, + pin) + } else if (SessionRequest.isTransactionMethod(request.method)) { + let txObj = SessionRequest.getTxObject(request.method, request.data) + if (!!payload) { + let hexFeesJson = root.store.convertFeesInfoToHex(JSON.stringify(payload)) + if (!!hexFeesJson) { + let feesInfo = JSON.parse(hexFeesJson) + if (feesInfo.maxFeePerGas) { + txObj.maxFeePerGas = feesInfo.maxFeePerGas + } + if (feesInfo.maxPriorityFeePerGas) { + txObj.maxPriorityFeePerGas = feesInfo.maxPriorityFeePerGas + } + } + delete txObj.gasLimit + delete txObj.gasPrice + } + // Remove nonce from txObj to be auto-filled by the wallet + delete txObj.nonce + + if (request.method === SessionRequest.methods.signTransaction.name) { + root.store.signTransaction(request.topic, + request.requestId, + request.accountAddress, + request.chainId, + txObj, + password, + pin) + } else if (request.method === SessionRequest.methods.sendTransaction.name) { + root.store.sendTransaction( + request.topic, + request.requestId, + request.accountAddress, + request.chainId, + txObj, + password, + pin) + } + } + + return true + } + + // Returns { + // maxFees -> Big number in Gwei + // maxFeePerGas + // maxPriorityFeePerGas + // gasPrice + // } + function getEstimatedMaxFees(tx, method, chainId, mainNetChainId) { + const BigOps = SQUtils.AmountsArithmetic + const gasLimit = BigOps.fromString("21000") + const parsedTransaction = SessionRequest.parseTransaction(tx, root.store.hexToDec) + let gasPrice = BigOps.fromString(parsedTransaction.maxFeePerGas) + let maxFeePerGas = BigOps.fromString(parsedTransaction.maxFeePerGas) + let maxPriorityFeePerGas = BigOps.fromString(parsedTransaction.maxPriorityFeePerGas) + let l1GasFee = BigOps.fromNumber(0) + + if (!maxFeePerGas || !maxPriorityFeePerGas || !gasPrice) { + const suggesteFees = getSuggestedFees(chainId) + maxFeePerGas = suggesteFees.maxFeePerGas + maxPriorityFeePerGas = suggesteFees.maxPriorityFeePerGas + gasPrice = suggesteFees.gasPrice + l1GasFee = suggesteFees.l1GasFee + } + + let maxFees = BigOps.times(gasLimit, gasPrice) + return {maxFees, maxFeePerGas, maxPriorityFeePerGas, gasPrice, l1GasFee} + } + + function getSuggestedFees(chainId) { + const BigOps = SQUtils.AmountsArithmetic + const fees = root.store.getSuggestedFees(chainId) + const maxPriorityFeePerGas = fees.maxPriorityFeePerGas + let maxFeePerGas + let gasPrice + if (fees.eip1559Enabled) { + if (!!fees.maxFeePerGasM) { + gasPrice = BigOps.fromNumber(fees.maxFeePerGasM) + maxFeePerGas = fees.maxFeePerGasM + } else if(!!tx.maxFeePerGas) { + let maxFeePerGasDec = root.store.hexToDec(tx.maxFeePerGas) + gasPrice = BigOps.fromString(maxFeePerGasDec) + maxFeePerGas = maxFeePerGasDec + } else { + console.error("Error fetching maxFeePerGas from fees or tx objects") + return + } + } else { + if (!!fees.gasPrice) { + gasPrice = BigOps.fromNumber(fees.gasPrice) + } else { + console.error("Error fetching suggested fees") + return + } + } + const l1GasFee = BigOps.fromNumber(fees.l1GasFee) + return {maxFeePerGas, maxPriorityFeePerGas, gasPrice, l1GasFee} + } + + // Returned values are Big numbers + function getEstimatedFeesStatus(tx, method, chainId, mainNetChainId) { + const BigOps = SQUtils.AmountsArithmetic + + const feesInfo = getEstimatedMaxFees(tx, method, chainId, mainNetChainId) + + const totalMaxFees = BigOps.sum(feesInfo.maxFees, feesInfo.l1GasFee) + const maxFeesEth = BigOps.div(totalMaxFees, BigOps.fromNumber(1, 9)) + + const maxFeesEthStr = maxFeesEth.toString() + const fiatMaxFeesStr = root.getFiatValue(maxFeesEthStr, Constants.ethToken) + const fiatMaxFees = BigOps.fromString(fiatMaxFeesStr) + const symbol = root.currentCurrency + + return {fiatMaxFees, maxFeesEth, symbol, feesInfo} + } + + function getBalanceInEth(balances, address, chainId) { + const BigOps = SQUtils.AmountsArithmetic + const accEth = SQUtils.ModelUtils.getFirstModelEntryIf(balances, (balance) => { + return balance.account.toLowerCase() === address.toLowerCase() && balance.chainId == chainId + }) + if (!accEth) { + console.error("Error balance lookup for account ", address, " on chain ", chainId) + return null + } + const accountFundsWei = BigOps.fromString(accEth.balance) + return BigOps.div(accountFundsWei, BigOps.fromNumber(1, 18)) + } + + // Returns {haveEnoughForFees, haveEnoughFunds} and true in case of error not to block request + function checkFundsStatus(maxFees, l1GasFee, address, chainId, mainNetChainId, value) { + const BigOps = SQUtils.AmountsArithmetic + let valueEth = BigOps.fromString(value) + let haveEnoughForFees = true + let haveEnoughFunds = true + + let token = SQUtils.ModelUtils.getByKey(root.groupedAccountAssetsModel, "tokensKey", Constants.ethToken) + if (!token || !token.balances) { + console.error("Error token balances lookup for ETH", SQUtils.ModelUtils.modelToArray(root.groupedAccountAssetsModel)) + console.error("Looking for tokensKey: ", Constants.ethToken) + return {haveEnoughForFees, haveEnoughFunds} + } + + let chainBalance = getBalanceInEth(token.balances, address, chainId) + if (!chainBalance) { + console.error("Error fetching chain balance") + return {haveEnoughForFees, haveEnoughFunds} + } + haveEnoughFunds = BigOps.cmp(chainBalance, valueEth) >= 0 + if (haveEnoughFunds) { + chainBalance = BigOps.sub(chainBalance, valueEth) + + if (chainId == mainNetChainId) { + const finalFees = BigOps.sum(maxFees, l1GasFee) + let feesEth = BigOps.div(finalFees, BigOps.fromNumber(1, 9)) + haveEnoughForFees = BigOps.cmp(chainBalance, feesEth) >= 0 + } else { + const feesChain = BigOps.div(maxFees, BigOps.fromNumber(1, 9)) + const haveEnoughOnChain = BigOps.cmp(chainBalance, feesChain) >= 0 + + const mainBalance = getBalanceInEth(token.balances, address, mainNetChainId) + if (!mainBalance) { + console.error("Error fetching mainnet balance") + return {haveEnoughForFees, haveEnoughFunds} + } + const feesMain = BigOps.div(l1GasFee, BigOps.fromNumber(1, 9)) + const haveEnoughOnMain = BigOps.cmp(mainBalance, feesMain) >= 0 + + haveEnoughForFees = haveEnoughOnChain && haveEnoughOnMain + } + } else { + haveEnoughForFees = false + } + + return {haveEnoughForFees, haveEnoughFunds} + } + + property int selectedFeesMode: Constants.FeesMode.Medium + + function getFeesForFeesMode(feesObj) { + if (!(feesObj.hasOwnProperty("maxFeePerGasL") && + feesObj.hasOwnProperty("maxFeePerGasM") && + feesObj.hasOwnProperty("maxFeePerGasH"))) { + throw new Error("inappropriate fees object provided") + } + + switch (d.selectedFeesMode) { + case Constants.FeesMode.Low: + return feesObj.maxFeePerGasL + case Constants.FeesMode.Medium: + return feesObj.maxFeePerGasM + case Constants.FeesMode.High: + return feesObj.maxFeePerGasH + default: + throw new Error("unknown selected mode") + } + } + + property var feesSubscriptions: [] + + function findSubscriptionIndex(topic, id) { + for (let i = 0; i < d.feesSubscriptions.length; i++) { + const subscription = d.feesSubscriptions[i] + if (subscription.topic == topic && subscription.id == id) { + return i + } + } + return -1 + } + + function findChainIndex(chainId) { + for (let i = 0; i < feesSubscription.chainIds.length; i++) { + if (feesSubscription.chainIds[i] == chainId) { + return i + } + } + return -1 + } + + function subscribeForFeeUpdates(topic, id) { + const request = requests.findRequest(topic, id) + if (request === null) { + console.error("Error finding event for subscribing for fees for topic", topic, "id", id) + return + } + + const index = d.findSubscriptionIndex(topic, id) + if (index >= 0) { + return + } + + d.feesSubscriptions.push({ + topic: topic, + id: id, + chainId: request.chainId + }) + + for (let i = 0; i < feesSubscription.chainIds.length; i++) { + if (feesSubscription.chainIds == request.chainId) { + return + } + } + + feesSubscription.chainIds.push(request.chainId) + feesSubscription.restart() + } + + function unsubscribeForFeeUpdates(topic, id) { + const index = d.findSubscriptionIndex(topic, id) + if (index == -1) { + return + } + + const chainId = d.feesSubscriptions[index].chainId + d.feesSubscriptions.splice(index, 1) + + const chainIndex = d.findChainIndex(chainId) + if (index == -1) { + return + } + + let found = false + for (let i = 0; i < d.feesSubscriptions.length; i++) { + if (d.feesSubscriptions[i].chainId == chainId) { + found = true + break + } + } + + if (found) { + return + } + + feesSubscription.chainIds.splice(chainIndex, 1) + if (feesSubscription.chainIds.length == 0) { + feesSubscription.stop() + } + } + } + + Timer { + id: feesSubscription + + property var chainIds: [] + + interval: 5000 + repeat: true + running: Qt.application.state === Qt.ApplicationActive + + onTriggered: { + for (let i = 0; i < chainIds.length; i++) { + for (let j = 0; j < d.feesSubscriptions.length; j++) { + let subscription = d.feesSubscriptions[j] + if (subscription.chainId == chainIds[i]) { + let request = requests.findRequest(subscription.topic, subscription.id) + if (request === null) { + console.error("Error updating fees for topic", subscription.topic, "id", subscription.id) + continue + } + d.updateFeesParamsToPassedObj(request) + } + } + } + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml index 822b55aa72..8e154466da 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweLifeCycle.qml @@ -174,8 +174,6 @@ SQUtils.QObject { topic: root.request.topic data: "" preparedData: "" - maxFeesText: "?" - maxFeesEthText: "?" expirationTimestamp: root.request.params.expiryTimestamp function onBuildAuthenticationObjectResult(id, authObject, error) { @@ -232,6 +230,7 @@ SQUtils.QObject { onAuthFailed: () => { try { + sdk.rejectSessionAuthenticate(request.requestId, true) const appDomain = SQUtils.StringUtils.extractDomainFromLink(request.dappUrl) const methodStr = SessionRequest.methodToUserString(request.method) if (!methodStr) { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml index dc02681ed7..9ee9d0fbf3 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SiweRequestPlugin.qml @@ -22,6 +22,8 @@ SQUtils.QObject { // - chainId required property var networksModel + readonly property bool enabled: sdk.enabled + // Trigger a connection request to the dApp // Expected `connectionApproved` to be called with the key and the approved namespaces signal connectDApp(var chains, string dAppUrl, string dAppName, string dAppIcon, var key) @@ -109,6 +111,7 @@ SQUtils.QObject { Connections { target: sdk + enabled: root.enabled function onSessionAuthenticateRequest(sessionData) { if (!sessionData || !sessionData.id) { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir index b9a33432d0..424d70b7fc 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir @@ -1,3 +1,5 @@ ChainsSupervisorPlugin 1.0 ChainsSupervisorPlugin.qml +DAppConnectionsPlugin 1.0 DAppConnectionsPlugin.qml SiweRequestPlugin 1.0 SiweRequestPlugin.qml -SiweLifeCycle 1.0 SiweLifeCycle.qml \ No newline at end of file +SiweLifeCycle 1.0 SiweLifeCycle.qml +SignRequestPlugin 1.0 SignRequestPlugin.qml \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/qmldir index 6735eff896..44d0300cc9 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/qmldir @@ -2,8 +2,8 @@ BCDappsProvider 1.0 BCDappsProvider.qml DappsConnectorSDK 1.0 DappsConnectorSDK.qml DAppsHelpers 1.0 helpers.js DAppsModel 1.0 DAppsModel.qml -DAppsRequestHandler 1.0 DAppsRequestHandler.qml +DAppsModule 1.0 DAppsModule.qml +DAppsService 1.0 DAppsService.qml WalletConnectSDKBase 1.0 WalletConnectSDKBase.qml WalletConnectSDK 1.0 WalletConnectSDK.qml -WalletConnectService 1.0 WalletConnectService.qml WCDappsProvider 1.0 WCDappsProvider.qml diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/Pairing.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/Pairing.qml index 0cd4568cd4..03a65c3ae9 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/Pairing.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/Pairing.qml @@ -13,5 +13,7 @@ QtObject { readonly property int unsupportedNetwork: 6 readonly property int unknownError: 7 readonly property int dappReadyForApproval: 8 + readonly property int userRejected: 9 + readonly property int rejectFailed: 10 } } \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml index e644aa38d2..c1e492ff3f 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml @@ -2,6 +2,8 @@ pragma Singleton import QtQml 2.15 +import AppLayouts.Wallet.services.dapps 1.0 +import StatusQ.Core.Utils 0.1 as SQUtils import utils 1.0 QtObject { @@ -62,6 +64,93 @@ QtObject { readonly property var all: [personalSign, sign, signTypedData_v4, signTypedData, sendTransaction] } + enum ErrorCode { + NoError, + InvalidAccount, + InvalidChainId, + InvalidData, + InvalidMessage, + InvalidMethod, + UnsupportedMethod, + InvalidRequest, + RuntimeError, + Ignored + } + /* + Function parsing the session request event + Throws exception if the event is invalid + returns { request: [Object], error: [ErrorCode] } + request: { + event, + topic, + requestId, + method, + account, + chainId, + data, + preparedData, + expiryTimestamp, + transaction: { + value, + maxFeePerGas, + maxPriorityFeePerGas, + gasPrice, + gasLimit, + nonce + } + } + */ + function parse(event, hexToDec) { + if (!event) { + console.warn("SessionRequest - parse - invalid event") + return { request: null, error: SessionRequest.InvalidRequest } + } + if (!hexToDec) { + hexToDec = (hex) => { return parseInt(hex, 16) } + } + let request = {} + let error = SessionRequest.NoError + + request.event = event + request.topic = event.topic + request.requestId = event.id + request.method = event.params.request.method + if (!request.method) { + console.warn("SessionRequest - build - invalid method") + return { request: null, error: SessionRequest.InvalidMethod } + } + if (getSupportedMethods().includes(request.method) === false) { + console.warn("Unsupported method:", request.method) + return { request: null, error: SessionRequest.UnsupportedMethod } + } + const { accountAddress, success } = getAccountFromEvent(event) + if (!accountAddress || !success) { + console.warn("SessionRequest - build - failed to get account from event") + return { request: null, error: SessionRequest.InvalidAccount } + } + request.account = accountAddress + request.data = getData(event) + if (!request.data) { + console.warn("SessionRequest - build - failed to get data from event") + return { request: null, error: SessionRequest.InvalidData } + } + const message = getMessage(event, hexToDec) + if (!message || !message.signData) { + console.warn("SessionRequest - build - failed to get message from event") + return { request: null, error: SessionRequest.InvalidMessage } + } + request.preparedData = message.signData + request.value = message.value + request.transaction = message.transaction + request.chainId = getChainId(event) + if (!request.chainId) { + console.warn("SessionRequest - build - failed to get chainId from event") + return { request: null, error: SessionRequest.InvalidChainId } + } + request.expiryTimestamp = getExpiryDate(event) + return { request, error} + } + function getSupportedMethods() { return methods.all.map(function(method) { return method.name @@ -76,4 +165,228 @@ QtObject { } return "" } + + /// returns { + /// accountAddress + /// success + /// } + /// if account is null and success is true it means that the account was not found + function getAccountFromEvent(event) { + const method = event.params.request.method + let address = "" + if (method === methods.personalSign.name) { + if (event.params.request.params.length < 2) { + return { accountAddress: "", success: false } + } + address = event.params.request.params[1] + } else if (method === methods.sign.name) { + if (event.params.request.params.length === 1) { + return { accountAddress: "", success: false } + } + address = event.params.request.params[0] + } else if(method === methods.signTypedData_v4.name || + method === methods.signTypedData.name) + { + if (event.params.request.params.length < 2) { + return { accountAddress: "", success: false } + } + address = event.params.request.params[0] + } else if (isTransactionMethod(method)) { + if (event.params.request.params.length == 0) { + return { accountAddress: "", success: false } + } + address = event.params.request.params[0].from + } else { + console.error("Unsupported method to lookup account: ", method) + return { accountAddress: "", success: false } + } + return { accountAddress: address, success: true } + } + + function getChainId(event) { + return DAppsHelpers.chainIdFromEip155(event.params.chainId) + } + + function getData(event) { + const method = event.params.request.method + if (method === methods.personalSign.name || + method === methods.sign.name) + { + if (event.params.request.params.length < 1) { + return null + } + let message = "" + const messageIndex = (method === methods.personalSign.name ? 0 : 1) + const messageParam = event.params.request.params[messageIndex] + // There is no standard on how data is encoded. Therefore we support hex or utf8 + if (DAppsHelpers.isHex(messageParam)) { + message = DAppsHelpers.hexToString(messageParam) + } else { + message = messageParam + } + return methods.personalSign.buildDataObject(message) + } else if (method === methods.signTypedData_v4.name || + method === methods.signTypedData.name) + { + if (event.params.request.params.length < 2) { + return null + } + const jsonMessage = event.params.request.params[1] + const methodObj = method === methods.signTypedData_v4.name + ? methods.signTypedData_v4 + : methods.signTypedData + return methodObj.buildDataObject(jsonMessage) + } else if (method === methods.signTransaction.name) { + if (event.params.request.params.length == 0) { + return null + } + const tx = event.params.request.params[0] + return methods.signTransaction.buildDataObject(tx) + } else if (method === methods.sendTransaction.name) { + if (event.params.request.params.length == 0) { + return null + } + const tx = event.params.request.params[0] + return methods.sendTransaction.buildDataObject(tx) + } else { + return null + } + } + + // returns { + // signData, + // transaction: { + // value, + // maxFeePerGas, + // maxPriorityFeePerGas, + // gasPrice, + // gasLimit, + // nonce + // }, + // value // null or ETH Big number + // } + function getMessage(event, hexToDec) { + const data = getData(event) + const method = event.params.request.method + return prepareData(method, data, hexToDec) + } + + // returns { + // signData, + // transaction: { + // value, + // maxFeePerGas, + // maxPriorityFeePerGas, + // gasPrice, + // gasLimit, + // nonce + // }, + // value // null or ETH Big number + // } + function prepareData(method, data, hexToDec) { + let payload = null + switch(method) { + case methods.personalSign.name: { + payload = methods.personalSign.getMessageFromData(data) + break + } + case methods.sign.name: { + payload = methods.sign.getMessageFromData(data) + break + } + case methods.signTypedData_v4.name: { + const stringPayload = methods.signTypedData_v4.getMessageFromData(data) + payload = JSON.stringify(JSON.parse(stringPayload), null, 2) + break + } + case methods.signTypedData.name: { + const stringPayload = methods.signTypedData.getMessageFromData(data) + payload = JSON.stringify(JSON.parse(stringPayload), null, 2) + break + } + case methods.signTransaction.name: + case methods.sendTransaction.name: + // For transactions we process the data in a different way as follows + break + default: + console.error("Unhandled method", method) + break; + } + + let value = SQUtils.AmountsArithmetic.fromNumber(0) + let txObj = getTxObject(method, data) + if (isTransactionMethod(method)) { + payload = parseTransaction(txObj, hexToDec) + if (payload.hasOwnProperty("value")) { + value = payload.value + } + } + return { + signData: payload, + transaction: txObj, + value: value + } + } + + /// Parses the transaction object and converts the values to human readable format + function parseTransaction(tx, hexToDec) { + let parsedTransaction = Object.assign({}, tx) + if (parsedTransaction.hasOwnProperty("value")) { + parsedTransaction.value = hexToEth(parsedTransaction.value, hexToDec).toString() + } + if (parsedTransaction.hasOwnProperty("maxFeePerGas")) { + parsedTransaction.maxFeePerGas = hexToGwei(parsedTransaction.maxFeePerGas, hexToDec).toString() + } + if (parsedTransaction.hasOwnProperty("maxPriorityFeePerGas")) { + parsedTransaction.maxPriorityFeePerGas = hexToGwei(parsedTransaction.maxPriorityFeePerGas, hexToDec).toString() + } + if (parsedTransaction.hasOwnProperty("gasPrice")) { + parsedTransaction.gasPrice = hexToGwei(parsedTransaction.gasPrice, hexToDec) + } + if (parsedTransaction.hasOwnProperty("gasLimit")) { + parsedTransaction.gasLimit = parseInt(hexToDec(parsedTransaction.gasLimit)) + } + if (parsedTransaction.hasOwnProperty("nonce")) { + parsedTransaction.nonce = parseInt(hexToDec(parsedTransaction.nonce)) + } + return parsedTransaction + } + + function hexToEth(value, hexToDec) { + return hexToEthDenomination(value, "eth", hexToDec) + } + function hexToGwei(value, hexToDec) { + return hexToEthDenomination(value, "gwei", hexToDec) + } + function hexToEthDenomination(value, ethUnit, hexToDec) { + let unitMapping = { + "gwei": 9, + "eth": 18 + } + let BigOps = SQUtils.AmountsArithmetic + let decValue = hexToDec(value) + if (!!decValue) { + return BigOps.div(BigOps.fromNumber(decValue), BigOps.fromNumber(1, unitMapping[ethUnit])) + } + return BigOps.fromNumber(0) + } + + function getTxObject(method, data) { + let tx + if (method === methods.signTransaction.name) { + tx = methods.signTransaction.getTxObjFromData(data) + } else if (method === methods.sendTransaction.name) { + tx = methods.sendTransaction.getTxObjFromData(data) + } + return tx + } + + function isTransactionMethod(method) { + return method === methods.signTransaction.name + || method === methods.sendTransaction.name + } + + function getExpiryDate(event) { + return event.params.request.expiryTimestamp + } } \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml index d1a604d1e3..8237cacb00 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml @@ -23,6 +23,7 @@ QObject { required property string chainId // optional expiry date in ms property var expirationTimestamp + property bool active: false // Maps to Constants.DAppConnectors values required property int sourceId @@ -31,32 +32,22 @@ QObject { // Data prepared for display in a human readable format required property var preparedData - property alias dappName: d.dappName - property alias dappUrl: d.dappUrl - property alias dappIcon: d.dappIcon + required property string dappName + required property string dappUrl + required property string dappIcon /// extra data resolved from wallet - property string maxFeesText: "" - property string maxFeesEthText: "" property bool haveEnoughFunds: false property bool haveEnoughFees: false property var /* Big */ fiatMaxFees property var /* Big */ ethMaxFees property var feesInfo + property var /* Big */ value /// maps to Constants.TransactionEstimatedTime values property int estimatedTimeCategory: 0 - function resolveDappInfoFromSession(session) { - let meta = session.peer.metadata - d.dappName = meta.name - d.dappUrl = meta.url - if (meta.icons && meta.icons.length > 0) { - d.dappIcon = meta.icons[0] - } - } - function isExpired() { return !!expirationTimestamp && expirationTimestamp > 0 && Math.floor(Date.now() / 1000) >= expirationTimestamp } @@ -65,13 +56,7 @@ QObject { expirationTimestamp = Math.floor(Date.now() / 1000) } - // dApp info - QtObject { - id: d - - property string dappName - property string dappUrl - property url dappIcon - property bool hasExpiry + function setActive() { + active = true } } \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolver.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolver.qml new file mode 100644 index 0000000000..34494a44c3 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolver.qml @@ -0,0 +1,78 @@ +pragma Singleton + +import QtQuick 2.15 + +import StatusQ.Core.Utils 0.1 as SQUtils + +/// Component that resolves a session request event +/// and returns a validated SessionRequest js object +/// @returns { +/// error: SessionRequest.Error, +/// request: { +/// event, - original event +/// topic, - dapp session identifier +/// requestId, - unique request identifier +/// method, - RPC method +/// account, - account address to sign the request +/// chainId, - chain id +/// data, - challenge data +/// preparedData, - human readable data +/// expiryTimestamp, - request expiry timestamp +/// transaction: { +/// value, +/// maxFeePerGas, +/// maxPriorityFeePerGas, +/// gasPrice, +/// gasLimit, +/// nonce +/// } +/// } +///} +SQUtils.QObject { + id: root + + function resolveEvent(event, accountsModel, networksModel, hexToDec) { + if (!event) { + console.warn("SessionRequestResolver - resolveEvent - invalid event") + return { request: null, error: SessionRequest.InvalidEvent } + } + if (!accountsModel) { + console.warn("SessionRequestResolver - resolveEvent - invalid accountsModel") + return { request: null, error: SessionRequest.RuntimeError } + } + if (!networksModel) { + console.warn("SessionRequestResolver - resolveEvent - invalid networksModel") + return { request: null, error: SessionRequest.RuntimeError } + } + + try { + const { request, error } = SessionRequest.parse(event, hexToDec) + if (error) { + console.warn("SessionRequestResolver - resolveEvent - failed to build request", error) + return { request: null, error } + } + if (!request) { + console.warn("SessionRequestResolver - resolveEvent - failed to build request") + return { request: null, error: SessionRequest.RuntimeError } + } + const validChainId = !!SQUtils.ModelUtils.getByKey(networksModel, "chainId", request.chainId) + if (!validChainId) { + console.warn("SessionRequestResolver - resolveEvent - invalid chainId", request.chainId) + return { request: null, error: SessionRequest.InvalidChainId } + } + + const validAccount = !!SQUtils.ModelUtils.getFirstModelEntryIf(accountsModel, (account) => { + return account.address.toLowerCase() === request.account.toLowerCase(); + }) + if (!validAccount) { + console.warn("SessionRequestResolver - resolveEvent - invalid account", request.account) + return { request: null, error: SessionRequest.InvalidAccount } + } + + return { request, error: SessionRequest.NoError } + } catch (e) { + console.warn("SessionRequestResolver - resolveEvent - failed to resolve event", e) + return { request: null, error: SessionRequest.RuntimeError } + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir index d5b4409b43..1529591bf8 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir @@ -2,5 +2,6 @@ SessionRequestResolved 1.0 SessionRequestResolved.qml SessionRequestWithAuth 1.0 SessionRequestWithAuth.qml SessionRequestsModel 1.0 SessionRequestsModel.qml singleton SessionRequest 1.0 SessionRequest.qml +singleton SessionRequestResolver 1.0 SessionRequestResolver.qml singleton Pairing 1.0 Pairing.qml singleton ErrorCodes 1.0 ErrorCodes.qml \ No newline at end of file diff --git a/ui/app/mainui/AppMain.qml b/ui/app/mainui/AppMain.qml index f0c31db77c..3dbde6b7a9 100644 --- a/ui/app/mainui/AppMain.qml +++ b/ui/app/mainui/AppMain.qml @@ -2168,57 +2168,57 @@ Item { } } - Component { - id: dappsConnectorSDK + Loader { + id: dAppsServiceLoader - DappsConnectorSDK { - active: WalletStores.RootStore.walletSectionInst.walletReady - controller: WalletStores.RootStore.dappsConnectorController - wcService: Global.walletConnectService - walletStore: WalletStores.RootStore - store: SharedStores.DAppsStore { - controller: WalletStores.RootStore.walletConnectController - } - loginType: appMain.rootStore.loginType + // It seems some of the functionality of the dapp connector depends on the DAppsService + active: { + return (featureFlagsStore.dappsEnabled || featureFlagsStore.connectorEnabled) && appMain.visible } - } - Loader { - id: dappsConnectorSDKLoader - active: featureFlagsStore.connectorEnabled - sourceComponent: dappsConnectorSDK - } - - Loader { - id: walletConnectServiceLoader - - // It seems some of the functionality of the dapp connector depends on the WalletConnectService - active: (featureFlagsStore.dappsEnabled || featureFlagsStore.connectorEnabled) && appMain.visible - - sourceComponent: WalletConnectService { - id: walletConnectService - - wcSDK: WalletConnectSDK { - enableSdk: WalletStores.RootStore.walletSectionInst.walletReady - userUID: appMain.rootStore.profileSectionStore.profileStore.pubkey - projectId: WalletStores.RootStore.appSettings.walletConnectProjectID + sourceComponent: DAppsService { + id: dAppsService + Component.onCompleted: { + Global.dAppsService = dAppsService } - store: SharedStores.DAppsStore { - controller: WalletStores.RootStore.walletConnectController - } - bcStore: SharedStores.BrowserConnectStore { - controller: WalletStores.RootStore.dappsConnectorController - } - walletRootStore: WalletStores.RootStore - blockchainNetworksDown: appMain.networkConnectionStore.blockchainNetworksDown + // DAppsModule provides the middleware for the dapps + dappsModule: DAppsModule { + currenciesStore: WalletStores.RootStore.currencyStore + groupedAccountAssetsModel: WalletStores.RootStore.walletAssetsStore.groupedAccountAssetsModel + accountsModel: WalletStores.RootStore.nonWatchAccounts + networksModel: SortFilterProxyModel { + sourceModel: WalletStores.RootStore.filteredFlatModel + proxyRoles: [ + FastExpressionRole { + name: "isOnline" + expression: !appMain.networkConnectionStore.blockchainNetworksDown.map(Number).includes(model.chainId) + expectedRoles: "chainId" + } + ] + } + wcSdk: WalletConnectSDK { + enabled: featureFlagsStore.dappsEnabled && WalletStores.RootStore.walletSectionInst.walletReady + userUID: appMain.rootStore.profileSectionStore.profileStore.pubkey + projectId: WalletStores.RootStore.appSettings.walletConnectProjectID + } + bcSdk: DappsConnectorSDK { + enabled: featureFlagsStore.connectorEnabled && WalletStores.RootStore.walletSectionInst.walletReady + store: SharedStores.BrowserConnectStore { + controller: WalletStores.RootStore.dappsConnectorController + } + networksModel: WalletStores.RootStore.filteredFlatModel + accountsModel: WalletStores.RootStore.nonWatchAccounts + } + store: SharedStores.DAppsStore { + controller: WalletStores.RootStore.walletConnectController + } + } + selectedAddress: WalletStores.RootStore.selectedAddress + accountsModel: WalletStores.RootStore.nonWatchAccounts connectorFeatureEnabled: featureFlagsStore.connectorEnabled walletConnectFeatureEnabled: featureFlagsStore.dappsEnabled - Component.onCompleted: { - Global.walletConnectService = walletConnectService - } - onDisplayToastMessage: (message, type) => { const icon = type === Constants.ephemeralNotificationType.danger ? "warning" : type === Constants.ephemeralNotificationType.success ? "checkmark-circle" : "info" diff --git a/ui/imports/shared/panels/AnimatedText.qml b/ui/imports/shared/panels/AnimatedText.qml index 115620d01b..6f8c443f4b 100644 --- a/ui/imports/shared/panels/AnimatedText.qml +++ b/ui/imports/shared/panels/AnimatedText.qml @@ -7,7 +7,7 @@ SequentialAnimation { property var target: null property color fromColor: Theme.palette.directColor1 - property color toColor: Theme.palette.baseColor5 + property color toColor: Theme.palette.getColor(fromColor, 0.1) property int duration: 500 // in milliseconds loops: 3 diff --git a/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml b/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml index 79b481fd03..3bf946f4bd 100644 --- a/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml +++ b/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml @@ -115,6 +115,7 @@ SignTransactionModalBase { AnimatedText { id: maxFeesAnimation target: maxFees + fromColor: maxFees.customColor } } } @@ -214,6 +215,7 @@ SignTransactionModalBase { AnimatedText { id: fiatFeesAnimation target: fiatFees + fromColor: fiatFees.customColor } } StatusTextWithLoadingState { @@ -236,6 +238,7 @@ SignTransactionModalBase { AnimatedText { id: cryptoFeesAnimation target: cryptoFees + fromColor: cryptoFees.customColor } } } diff --git a/ui/imports/shared/stores/BrowserConnectStore.qml b/ui/imports/shared/stores/BrowserConnectStore.qml index aa2e7c878b..40a89c498a 100644 --- a/ui/imports/shared/stores/BrowserConnectStore.qml +++ b/ui/imports/shared/stores/BrowserConnectStore.qml @@ -18,8 +18,8 @@ SQUtils.QObject { signal approveConnectResponse(string id, bool error) signal rejectConnectResponse(string id, bool error) - signal approveTransactionResponse(string requestId, bool error) - signal rejectTransactionResponse(string requestId, bool error) + signal approveTransactionResponse(string topic, string requestId, bool error) + signal rejectTransactionResponse(string topic, string requestId, bool error) function approveConnection(id, account, chainId) { return controller.approveConnection(id, account, chainId) @@ -29,12 +29,12 @@ SQUtils.QObject { return controller.rejectConnection(id, error) } - function approveTransaction(requestId, signature) { - return controller.approveTransaction(requestId, signature) + function approveTransaction(topic, requestId, signature) { + return controller.approveTransaction(topic, requestId, signature) } - function rejectTransaction(requestId, error) { - return controller.rejectTransaction(requestId, error) + function rejectTransaction(topic, requestId, error) { + return controller.rejectTransaction(topic, requestId, error) } function disconnect(id) { @@ -72,12 +72,12 @@ SQUtils.QObject { root.rejectConnectResponse(id, error) } - function onApproveTransactionResponse(requestId, error) { - root.approveTransactionResponse(requestId, error) + function onApproveTransactionResponse(topic, requestId, error) { + root.approveTransactionResponse(topic, requestId, error) } - function onRejectTransactionResponse(requestId, error) { - root.rejectTransactionResponse(requestId, error) + function onRejectTransactionResponse(topic, requestId, error) { + root.rejectTransactionResponse(topic, requestId, error) } } } \ No newline at end of file diff --git a/ui/imports/utils/Global.qml b/ui/imports/utils/Global.qml index d5111d97b6..1b956c4bd9 100644 --- a/ui/imports/utils/Global.qml +++ b/ui/imports/utils/Global.qml @@ -10,7 +10,7 @@ QtObject { property bool appIsReady: false // use the generic var as type to break the cyclic dependency - property var walletConnectService: null + property var dAppsService: null signal openPinnedMessagesPopupRequested(var store, var messageStore, var pinnedMessagesModel, string messageToPin, string chatId) signal openCommunityProfilePopupRequested(var store, var community, var chatCommunitySectionModule)