diff --git a/storybook/pages/DAppsWorkflowPage.qml b/storybook/pages/DAppsWorkflowPage.qml index 0541385796..0f86d04bec 100644 --- a/storybook/pages/DAppsWorkflowPage.qml +++ b/storybook/pages/DAppsWorkflowPage.qml @@ -64,9 +64,42 @@ Item { spacing: 8 - wcService: walletConnectService + readonly property var wcService: walletConnectService loginType: Constants.LoginType.Biometrics selectedAccountAddress: "" + + model: wcService.dappsModel + accountsModel: wcService.validAccounts + networksModel: wcService.flatNetworks + sessionRequestsModel: wcService.sessionRequestsModel + + //formatBigNumber: (number, symbol, noSymbolOption) => wcService.walletRootStore.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*/) + + Connections { + target: dappsWorkflow.wcService + function onPairingValidated(validationState) { + dappsWorkflow.pairingValidated(validationState) + } + function onApproveSessionResult(pairingId, err, newConnectionId) { + if (err) { + dappsWorkflow.connectionFailed(pairingId) + return + } + + dappsWorkflow.connectionSuccessful(pairingId, newConnectionId) + } + function onConnectDApp(dappChains, dappUrl, dappName, dappIcon, pairingId) { + dappsWorkflow.connectDApp(dappChains, dappUrl, dappName, dappIcon, pairingId) + } + } } } ColumnLayout {} @@ -129,7 +162,7 @@ Item { ListView { Layout.fillWidth: true Layout.preferredHeight: Math.min(50, contentHeight) - model: walletConnectService.requestHandler.requestsModel + model: walletConnectService.sessionRequestsModel delegate: RowLayout { StatusBaseText { text: SQUtils.Utils.elideAndFormatWalletAddress(model.topic, 6, 4) @@ -309,15 +342,25 @@ Item { signal userAuthenticated(string topic, string id, string password, string pin) signal userAuthenticationFailed(string topic, string id) signal signingResult(string topic, string id, string data) + signal activeSessionsReceived(var activeSessionsJsonObj, bool success) function addWalletConnectSession(sessionJson) { - console.info("Persist Session", sessionJson) - + console.info("Add Persisted Session", sessionJson) let session = JSON.parse(sessionJson) d.updateSessionsModelAndAddNewIfNotNull(session) - return true } + + function getActiveSessions() { + console.info("Get Active Sessions") + let sessions = JSON.parse(settings.persistedSessions) + let response = sessions.map(function(session) { + return { + sessionJson: JSON.stringify(session), + } + }) + activeSessionsReceived(response, true) + } function deactivateWalletConnectSession(topic) { console.info("Deactivate Persisted Session", topic) diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml b/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml index 166d5019ec..8d4c179624 100644 --- a/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml +++ b/ui/StatusQ/src/StatusQ/Core/Utils/ModelUtils.qml @@ -157,4 +157,16 @@ QtObject { return null } + + function forEach(model, callback) { + if (!model) + return + + const count = model.rowCount() + + for (let i = 0; i < count; i++) { + const modelItem = Internal.ModelUtils.get(model, i) + callback(modelItem) + } + } } diff --git a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml index e24ad135d2..7f761b0dfd 100644 --- a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml +++ b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml @@ -18,14 +18,46 @@ import utils 1.0 DappsComboBox { id: root - required property WalletConnectService wcService // Values mapped to Constants.LoginType required property int loginType + property var accountsModel + property var networksModel + property SessionRequestsModel sessionRequestsModel property string selectedAccountAddress + property var formatBigNumber: (number, symbol, noSymbolOption) => console.error("formatBigNumber not set") + signal pairWCReady() - model: root.wcService.dappsModel + 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 signRequestAccepted(string connectionId, string requestId) + signal signRequestRejected(string connectionId, string requestId) + + /// Response to pairingValidationRequested + function pairingValidated(validationState) { + if (pairWCLoader.item) { + pairWCLoader.item.pairingValidated(validationState) + } + } + + /// Confirmation received on connectionAccepted + function connectionSuccessful(pairingId, newConnectionId) { + connectDappLoader.connectionSuccessful(pairingId, newConnectionId) + } + + /// Confirmation received on connectionAccepted + function connectionFailed(pairingId) { + connectDappLoader.connectionFailed(pairingId) + } + + /// Request to connect to a dApp + function connectDApp(dappChains, dappUrl, dappName, dappIcon, pairingId) { + connectDappLoader.connect(dappChains, dappUrl, dappName, dappIcon, pairingId) + } onPairDapp: { pairWCLoader.active = true @@ -44,7 +76,7 @@ DappsComboBox { active: false onLoaded: { - const dApp = wcService.getDApp(dAppUrl); + const dApp = SQUtils.ModelUtils.getByKey(root.model, "url", dAppUrl); if (dApp) { item.dappName = dApp.name; item.dappIcon = dApp.iconUrl; @@ -63,7 +95,11 @@ DappsComboBox { } onAccepted: { - root.wcService.disconnectDapp(dappUrl) + SQUtils.ModelUtils.forEach(model, (dApp) => { + if (dApp.url === dAppUrl) { + root.disconnectRequested(dApp.topic) + } + }) } } } @@ -82,15 +118,8 @@ DappsComboBox { visible: true onClosed: pairWCLoader.active = false - - onPair: (uri) => { - this.isPairing = true - root.wcService.pair(uri) - } - - onPairUriChanged: (uri) => { - root.wcService.validatePairingUri(uri) - } + onPair: (uri) => root.pairingRequested(uri) + onPairUriChanged: (uri) => root.pairingValidationRequested(uri) } } @@ -99,21 +128,67 @@ DappsComboBox { 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: [] } + // Array of chaind ids + property var dappChains + property url dappUrl + property string dappName + property url dappIcon + property var key + property var topic + + property var connectionQueue: [] + onActiveChanged: { + if (!active && connectionQueue.length > 0) { + connect(connectionQueue[0].dappChains, + connectionQueue[0].dappUrl, + connectionQueue[0].dappName, + connectionQueue[0].dappIcon, + connectionQueue[0].key) + connectionQueue.shift() + } + } + + function connect(dappChains, dappUrl, dappName, dappIcon, key) { + if (connectDappLoader.active) { + connectionQueue.push({ dappChains, dappUrl, dappName, dappIcon, key }) + return + } + + connectDappLoader.dappChains = dappChains + connectDappLoader.dappUrl = dappUrl + connectDappLoader.dappName = dappName + connectDappLoader.dappIcon = dappIcon + connectDappLoader.key = key + + if (pairWCLoader.item) { + // Allow user to get the uri valid confirmation + pairWCLoader.item.pairingValidated(Pairing.errors.dappReadyForApproval) + connectDappTimer.start() + } else { + connectDappLoader.active = true + } + } + + function connectionSuccessful(key, newTopic) { + if (connectDappLoader.key === key && connectDappLoader.item) { + connectDappLoader.topic = newTopic + connectDappLoader.item.pairSuccessful() + } + } + + function connectionFailed(id) { + if (connectDappLoader.key === key && connectDappLoader.item) { + connectDappLoader.item.pairFailed() + } + } sourceComponent: ConnectDAppModal { visible: true - + onClosed: connectDappLoader.active = false - accounts: root.wcService.validAccounts + accounts: root.accountsModel flatNetworks: SortFilterProxyModel { - sourceModel: root.wcService.flatNetworks + sourceModel: root.networksModel filters: [ FastExpressionFilter { inverted: true @@ -124,41 +199,45 @@ DappsComboBox { } selectedAccountAddress: root.selectedAccountAddress - dAppUrl: proposalMedatada.url - dAppName: proposalMedatada.name - dAppIconUrl: !!proposalMedatada.icons && proposalMedatada.icons.length > 0 ? proposalMedatada.icons[0] : "" + dAppUrl: connectDappLoader.dappUrl + dAppName: connectDappLoader.dappName + dAppIconUrl: connectDappLoader.dappIcon onConnect: { - root.wcService.approvePairSession(sessionProposal, selectedChains, selectedAccount) + if (!selectedAccount || !selectedAccount.address) { + console.error("Missing account selection") + return + } + if (!selectedChains || selectedChains.length === 0) { + console.error("Missing chain selection") + return + } + + root.connectionAccepted(connectDappLoader.key, selectedChains, selectedAccount.address) } onDecline: { - connectDappLoader.active = false - root.wcService.rejectPairSession(sessionProposal.id) + root.connectionDeclined(connectDappLoader.key) + close() } - onDisconnect: { - connectDappLoader.active = false - root.wcService.disconnectSession(sessionTopic) + onDisconnect: { + root.disconnectRequested(connectDappLoader.topic) + close() } } } - Loader { - id: sessionRequestLoader - - active: false - - onLoaded: item.open() - - property SessionRequestResolved request: null - property bool requestHandled: false - - sourceComponent: DAppSignRequestModal { + Instantiator { + model: root.sessionRequestsModel + delegate: DAppSignRequestModal { id: dappRequestModal objectName: "dappsRequestModal" - property var feesInfo: null + required property var model + required property int index + + readonly property var request: model.requestItem readonly property var account: accountEntry.available ? accountEntry.item : { name: "", address: "", @@ -171,10 +250,24 @@ DappsComboBox { chainName: "", iconUrl: "" } + property bool requestHandled: false + + function rejectRequest() { + // Allow rejecting only once + if (requestHandled) { + return + } + requestHandled = true + let userRejected = true + root.signRequestRejected(request.topic, request.id) + } + + parent: root loginType: account.migratedToKeycard ? Constants.LoginType.Keycard : root.loginType - formatBigNumber: (number, symbol, noSymbolOption) => root.wcService.walletRootStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption) - visible: true + formatBigNumber: root.formatBigNumber + + visible: !!request.dappUrl dappUrl: request.dappUrl dappIcon: request.dappIcon @@ -188,143 +281,47 @@ DappsComboBox { networkName: network.chainName networkIconPath: Style.svg(network.iconUrl) - fiatFees: request.maxFeesText - cryptoFees: request.maxFeesEthText - estimatedTime: "" - feesLoading: !request.maxFeesText || !request.maxFeesEthText + fiatFees: request.fiatMaxFees ? request.fiatMaxFees.toFixed() : "" + cryptoFees: request.ethMaxFees ? request.ethMaxFees.toFixed() : "" + estimatedTime: WalletUtils.getLabelForEstimatedTxTime(request.estimatedTimeCategory) + feesLoading: hasFees && (!fiatFees || !cryptoFees) hasFees: signingTransaction - enoughFundsForTransaction: request.enoughFunds - enoughFundsForFees: request.enoughFunds + enoughFundsForTransaction: request.haveEnoughFunds + enoughFundsForFees: request.haveEnoughFees signingTransaction: !!request.method && (request.method === SessionRequest.methods.signTransaction.name || request.method === SessionRequest.methods.sendTransaction.name) requestPayload: request.preparedData + onClosed: { - Qt.callLater( () => { - rejectRequest() - sessionRequestLoader.active = false - }) + Qt.callLater(rejectRequest) } onAccepted: { - if (!request) { - console.error("Error signing: request is null") - return - } - requestHandled = true - root.wcService.requestHandler.authenticate(request, JSON.stringify(feesInfo)) + root.signRequestAccepted(request.topic, request.id) } onRejected: { rejectRequest() } - function rejectRequest() { - // Allow rejecting only once - if (requestHandled) { - return - } - requestHandled = true - let userRejected = true - root.wcService.requestHandler.rejectSessionRequest(request, userRejected) - } - - Connections { - target: root.wcService.requestHandler - - function onMaxFeesUpdated(fiatMaxFees, ethMaxFees, haveEnoughFunds, haveEnoughFees, symbol, feesInfo) { - dappRequestModal.hasFees = !!ethMaxFees - dappRequestModal.feesLoading = !dappRequestModal.hasFees - if (!dappRequestModal.hasFees) { - return - } - dappRequestModal.fiatFees = fiatMaxFees.toFixed() - dappRequestModal.cryptoFees = ethMaxFees.toFixed() - dappRequestModal.enoughFundsForTransaction = haveEnoughFunds - dappRequestModal.enoughFundsForFees = haveEnoughFees - dappRequestModal.feesInfo = feesInfo - } - - function onEstimatedTimeUpdated(estimatedTimeEnum) { - dappRequestModal.estimatedTime = WalletUtils.getLabelForEstimatedTxTime(estimatedTimeEnum) - } - } - ModelEntry { id: accountEntry - sourceModel: root.wcService.validAccounts + sourceModel: root.accountsModel key: "address" value: request.accountAddress } ModelEntry { id: networkEntry - sourceModel: root.wcService.flatNetworks + sourceModel: root.networksModel key: "chainId" value: request.chainId } } } - Connections { - target: root.wcService ? root.wcService.requestHandler : null - - function onSessionRequestResult(request, isSuccess) { - if (isSuccess) { - sessionRequestLoader.active = false - } else { - // TODO #14762 handle the error case - let userRejected = false - root.wcService.requestHandler.rejectSessionRequest(request, userRejected) - } - } - } - - Connections { - target: root.wcService - - function onPairingValidated(validationState) { - if (pairWCLoader.item) { - pairWCLoader.item.pairingValidated(validationState) - } - } - - function onConnectDApp(dappChains, sessionProposal, availableNamespaces) { - connectDappLoader.dappChains = dappChains - connectDappLoader.sessionProposal = sessionProposal - connectDappLoader.availableNamespaces = availableNamespaces - connectDappLoader.sessionTopic = null - - if (pairWCLoader.item) { - // Allow user to get the uri valid confirmation - pairWCLoader.item.pairingValidated(Pairing.errors.dappReadyForApproval) - connectDappTimer.start() - } else { - connectDappLoader.active = true - } - } - - function onApproveSessionResult(session, err) { - connectDappLoader.sessionTopic = session.topic - - let modal = connectDappLoader.item - if (!!modal) { - if (err) { - modal.pairFailed(session, err) - } else { - modal.pairSuccessful(session) - } - } - } - - function onSessionRequest(request) { - sessionRequestLoader.request = request - sessionRequestLoader.requestHandled = false - sessionRequestLoader.active = true - } - } - // Used between transitioning from PairWCModal to ConnectDAppModal Timer { id: connectDappTimer diff --git a/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml b/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml index 092f3a3aa0..ca17865426 100644 --- a/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml +++ b/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml @@ -16,6 +16,7 @@ import SortFilterProxyModel 0.2 import shared.stores 1.0 import AppLayouts.Wallet.stores 1.0 as WalletStores +import AppLayouts.Wallet.services.dapps 1.0 import utils 1.0 @@ -135,19 +136,53 @@ Item { } DAppsWorkflow { + id: dappsWorkflow Layout.alignment: Qt.AlignTop + readonly property WalletConnectService wcService: Global.walletConnectService + spacing: 8 visible: !root.walletStore.showSavedAddresses && root.dappsEnabled - && Global.walletConnectService.isServiceAvailableForAddressSelection + && wcService.serviceAvailableToCurrentAddress enabled: !!Global.walletConnectService - wcService: Global.walletConnectService loginType: root.loginType selectedAccountAddress: root.walletStore.selectedAddress + model: wcService.dappsModel + accountsModel: wcService.validAccounts + networksModel: wcService.flatNetworks + sessionRequestsModel: wcService.sessionRequestsModel + + formatBigNumber: (number, symbol, noSymbolOption) => wcService.walletRootStore.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*/) + + Connections { + target: dappsWorkflow.wcService + function onPairingValidated(validationState) { + dappsWorkflow.pairingValidated(validationState) + } + function onApproveSessionResult(pairingId, err, newConnectionId) { + if (err) { + dappsWorkflow.connectionFailed(pairingId) + return + } + + dappsWorkflow.connectionSuccessful(pairingId, newConnectionId) + } + function onConnectDApp(dappChains, dappUrl, dappName, dappIcon, pairingId) { + dappsWorkflow.connectDApp(dappChains, dappUrl, dappName, dappIcon, pairingId) + } + } } StatusButton { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/ConnectorDAppsListProvider.qml b/ui/app/AppLayouts/Wallet/services/dapps/ConnectorDAppsListProvider.qml index ba49a136e1..438083506f 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/ConnectorDAppsListProvider.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/ConnectorDAppsListProvider.qml @@ -1,6 +1,8 @@ import QtQuick 2.15 -import StatusQ.Core.Utils 0.1 + import AppLayouts.Wallet.services.dapps 1.0 +import StatusQ.Core.Utils 0.1 + import shared.stores 1.0 import utils 1.0 @@ -8,17 +10,39 @@ QObject { id: root readonly property alias dappsModel: d.dappsModel + readonly property int connectorId: Constants.StatusConnect - function addSession(session) { - d.addSession(session) + function addSession(url, name, iconUrl, accountAddress) { + if (!url || !name || !iconUrl || !accountAddress) { + console.error("addSession: missing required parameters") + return + } + + const topic = url + const activeSession = getActiveSession(topic) + if (!activeSession) { + d.addSession({ + url, + name, + iconUrl, + topic, + connectorId: root.connectorId, + accountAddresses: [{address: accountAddress}] + }) + return + } + + if (!ModelUtils.contains(activeSession.accountAddresses, "address", accountAddress, Qt.CaseInsensitive)) { + activeSession.accountAddresses.append({address: accountAddress}) + } } - function revokeSession(session) { - d.revokeSession(session) + function revokeSession(topic) { + d.revokeSession(topic) } - function getActiveSession(dAppUrl) { - return d.getActionSession(dAppUrl) + function getActiveSession(topic) { + return d.getActiveSession(topic) } QObject { @@ -28,16 +52,14 @@ QObject { id: dapps } - function addSession(dappInfo) { - let dappItem = JSON.parse(dappInfo) + function addSession(dappItem) { dapps.append(dappItem) } - function revokeSession(dappInfo) { - let dappItem = JSON.parse(dappInfo) + function revokeSession(topic) { for (let i = 0; i < dapps.count; i++) { let existingDapp = dapps.get(i) - if (existingDapp.url === dappItem.url) { + if (existingDapp.topic === topic) { dapps.remove(i) break } @@ -50,19 +72,21 @@ QObject { } } - function getActionSession(dAppUrl) { + function getActiveSession(topic) { for (let i = 0; i < dapps.count; i++) { - let existingDapp = dapps.get(i) + const existingDapp = dapps.get(i) - if (existingDapp.url === dAppUrl) { - return JSON.stringify({ + if (existingDapp.topic === topic) { + return { name: existingDapp.name, url: existingDapp.url, - icon: existingDapp.iconUrl - }); + icon: existingDapp.iconUrl, + topic: existingDapp.topic, + connectorId: existingDapp.connectorId, + accountAddresses: existingDapp.accountAddresses + }; } } - return null } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml index a12a45545e..28e42518dc 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsListProvider.qml @@ -3,8 +3,6 @@ import QtQuick 2.15 import StatusQ 0.1 import StatusQ.Core.Utils 0.1 -import SortFilterProxyModel 0.2 - import AppLayouts.Wallet.services.dapps 1.0 import shared.stores 1.0 @@ -17,25 +15,8 @@ QObject { required property WalletConnectSDKBase sdk required property DAppsStore store required property var supportedAccountsModel - - property string selectedAddress: "" - - readonly property SortFilterProxyModel dappsModel: SortFilterProxyModel { - objectName: "DAppsModelFiltered" - sourceModel: d.dappsModel - - filters: FastExpressionFilter { - enabled: !!root.selectedAddress - - function isAddressIncluded(accountAddressesSubModel, selectedAddress) { - const addresses = ModelUtils.modelToFlatArray(accountAddressesSubModel, "address") - return addresses.includes(root.selectedAddress) - } - expression: isAddressIncluded(model.accountAddresses, root.selectedAddress) - - expectedRoles: "accountAddresses" - } - } + readonly property int connectorId: Constants.WalletConnect + readonly property var dappsModel: d.dappsModel function updateDapps() { d.updateDappsModel() @@ -66,7 +47,10 @@ QObject { url: cachedEntry.url, name: cachedEntry.name, iconUrl: cachedEntry.iconUrl, - accountAddresses: [{address: ''}] + accountAddresses: [], + topic: "", + connectorId: root.connectorId, + sessions: [] } dapps.append(dappEntryWithRequiredRoles); } @@ -86,6 +70,7 @@ QObject { const dAppsMap = {} const topics = [] const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(allSessionsAllProfiles, supportedAccountsModel) + for (const sessionID in sessions) { const session = sessions[sessionID] const dapp = session.peer.metadata @@ -101,11 +86,13 @@ QObject { // more modern syntax (ES-6) is not available yet const combinedAddresses = new Set(existingDApp.accountAddresses.concat(accounts)); existingDApp.accountAddresses = Array.from(combinedAddresses); + dapp.sessions = [...existingDApp.sessions, session] } else { dapp.accountAddresses = accounts + dapp.topic = sessionID + dapp.sessions = [session] dAppsMap[dapp.url] = dapp } - topics.push(sessionID) } @@ -113,11 +100,12 @@ QObject { dapps.clear(); // Iterate dAppsMap and fill dapps - for (const topic in dAppsMap) { - const dAppEntry = dAppsMap[topic]; + for (const uri in dAppsMap) { + const dAppEntry = dAppsMap[uri]; // Due to ListModel converting flat array to empty nested ListModel // having array of key value pair fixes the problem dAppEntry.accountAddresses = dAppEntry.accountAddresses.filter(account => (!!account)).map(account => ({address: account})); + dAppEntry.connectorId = root.connectorId; dapps.append(dAppEntry); } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml index bdeebd3070..35255f1e51 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml @@ -23,22 +23,17 @@ SQUtils.QObject { property alias requestsModel: requests - function rejectSessionRequest(request, userRejected) { - let error = userRejected ? false : true - sdk.rejectSessionRequest(request.topic, request.id, error) + function rejectSessionRequest(topic, id, hasError) { + sdk.rejectSessionRequest(topic, id, hasError) } /// Beware, it will fail if called multiple times before getting an answer - function authenticate(request, payload) { - return store.authenticateUser(request.topic, request.id, request.accountAddress, payload) + function authenticate(topic, id, address, payload) { + return store.authenticateUser(topic, id, address, payload) } - signal sessionRequest(SessionRequestResolved request) + signal sessionRequest(string id) signal displayToastMessage(string message, bool error) - signal sessionRequestResult(/*model entry of SessionRequestResolved*/ var request, bool isSuccess) - signal maxFeesUpdated(var /* Big */ fiatMaxFees, var /* Big */ ethMaxFees, bool haveEnoughFunds, bool haveEnoughFees, string symbol, var feesInfo) - // Reports Constants.TransactionEstimatedTime values - signal estimatedTimeUpdated(int estimatedTimeEnum) Connections { target: sdk @@ -75,19 +70,19 @@ SQUtils.QObject { d.lookupSession(topic, function(session) { if (session === null) return + const appUrl = session.peer.metadata.url + const appDomain = SQUtils.StringUtils.extractDomainFromLink(appUrl) if (error) { - root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(session.peer.metadata.url), true) + root.displayToastMessage(qsTr("Fail to %1 from %2").arg(methodStr).arg(appDomain), true) - root.sessionRequestResult(request, false /*isSuccessful*/) + root.rejectSessionRequest(topic, id, true /*hasError*/) console.error(`Error accepting session request for topic: ${topic}, id: ${id}, accept: ${accept}, error: ${error}`) return } let actionStr = accept ? qsTr("accepted") : qsTr("rejected") - root.displayToastMessage("%1 %2 %3".arg(session.peer.metadata.url).arg(methodStr).arg(actionStr), false) - - root.sessionRequestResult(request, true /*isSuccessful*/) + root.displayToastMessage("%1 %2 %3".arg(appDomain).arg(methodStr).arg(actionStr), false) }) } } @@ -113,24 +108,19 @@ SQUtils.QObject { d.lookupSession(topic, function(session) { if (session === null) return - root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(session.peer.metadata.url), true) - root.sessionRequestResult(request, false /*isSuccessful*/) + const appDomain = SQUtils.StringUtils.extractDomainFromLink(session.peer.metadata.url) + root.displayToastMessage(qsTr("Failed to authenticate %1 from %2").arg(methodStr).arg(appDomain), true) + root.rejectSessionRequest(topic, id, false /*hasErrors*/) }) } function onSigningResult(topic, id, data) { - let isSuccessful = (data != "") - if (isSuccessful) { + let hasErrors = (data == "") + if (!hasErrors) { // acceptSessionRequest will trigger an sdk.sessionRequestUserAnswerResult signal sdk.acceptSessionRequest(topic, id, data) } else { - console.error("signing error") - var request = requests.findRequest(topic, id) - if (request === null) { - console.error("Error finding event for topic", topic, "id", id) - return - } - root.sessionRequestResult(request, isSuccessful) + root.rejectSessionRequest(topic, id, hasErrors) } } } @@ -167,7 +157,7 @@ SQUtils.QObject { return { obj: null, code: resolveAsyncResult.error } } - let data = extractMethodData(event, method) + const data = extractMethodData(event, method) if(!data) { console.error("Error in event data lookup", JSON.stringify(event)) return { obj: null, code: resolveAsyncResult.error } @@ -175,7 +165,8 @@ SQUtils.QObject { const interpreted = d.prepareData(method, data) - let enoughFunds = !d.isTransactionMethod(method) + const enoughFunds = !d.isTransactionMethod(method) + let obj = sessionRequestComponent.createObject(null, { event, topic: event.topic, @@ -205,16 +196,15 @@ SQUtils.QObject { console.error("DAppsRequestHandler.lookupSession: error finding session for topic", obj.topic) return } + obj.resolveDappInfoFromSession(session) - root.sessionRequest(obj) + root.sessionRequest(obj.id) if (!d.isTransactionMethod(method)) { return } - - let estimatedTimeEnum = getEstimatedTimeInterval(data, method, obj.chainId) - root.estimatedTimeUpdated(estimatedTimeEnum) + obj.estimatedTimeCategory = getEstimatedTimeInterval(data, method, obj.chainId) const mainNet = lookupMainnetNetwork() let mainChainId = obj.chainId @@ -224,11 +214,12 @@ SQUtils.QObject { console.error("Error finding mainnet network") } let st = getEstimatedFeesStatus(data, method, obj.chainId, mainChainId) - let fundsStatus = checkFundsStatus(st.feesInfo.maxFees, st.feesInfo.l1GasFee, obj.accountAddress, obj.chainId, mainNet.chainId, interpreted.value) - - root.maxFeesUpdated(st.fiatMaxFees, st.maxFeesEth, fundsStatus.haveEnoughFunds, - fundsStatus.haveEnoughForFees, st.symbol, st.feesInfo) + obj.fiatMaxFees = st.fiatMaxFees + obj.ethMaxFees = st.maxFeesEth + obj.haveEnoughFunds = fundsStatus.haveEnoughFunds + obj.haveEnoughFees = fundsStatus.haveEnoughForFees + obj.feesInfo = st.feesInfo }) return { @@ -696,6 +687,7 @@ SQUtils.QObject { id: sessionRequestComponent SessionRequestResolved { + sourceId: Constants.DAppConnectors.WalletConnect } } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml b/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml index 6b292092f8..f1426c51b3 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DappsConnectorSDK.qml @@ -40,6 +40,7 @@ WalletConnectSDKBase { property alias requestsModel: requests readonly property string invalidDAppUrlError: "Invalid dappInfo: URL is missing" + readonly property string invalidDAppTopicError: "Invalid dappInfo: failed to parse topic" projectId: "" @@ -432,15 +433,13 @@ WalletConnectSDKBase { }; } - let sessionString = root.wcService.connectorDAppsProvider.getActiveSession(dappInfos.url) - if (sessionString === null) { + let session = root.wcService.connectorDAppsProvider.getActiveSession(dappInfos.url) + if (!session) { console.error("Connector.lookupSession: error finding session for requestId ", root.requestId) return } - let session = JSON.parse(sessionString); - return sessionTemplate(session.url, session.name, session.icon) } @@ -652,6 +651,7 @@ WalletConnectSDKBase { id: sessionRequestComponent SessionRequestResolved { + sourceId: Constants.DAppConnectors.StatusConnect } } @@ -662,15 +662,14 @@ WalletConnectSDKBase { Connections { target: root.wcService - function onRevokeSession(dAppUrl) { - if (!dAppUrl) { - console.warn(invalidDAppUrlError) + function onRevokeSession(topic) { + if (!topic) { + console.warn(invalidDAppTopicError) return } - controller.recallDAppPermission(dAppUrl) - const session = { url: dAppUrl, name: "", icon: "" } - root.wcService.connectorDAppsProvider.revokeSession(JSON.stringify(session)) + controller.recallDAppPermission(topic) + root.wcService.connectorDAppsProvider.revokeSession(topic) } } @@ -753,8 +752,8 @@ WalletConnectSDKBase { console.warn(invalidDAppUrlError) return } - const session = { url, name, iconUrl } - root.wcService.connectorDAppsProvider.addSession(JSON.stringify(session)) + + root.wcService.connectorDAppsProvider.addSession(url, name, iconUrl) } onDappRevokeDAppPermission: function(dappInfoString) { @@ -762,7 +761,8 @@ WalletConnectSDKBase { let session = { "url": dappItem.url, "name": dappItem.name, - "iconUrl": dappItem.icon + "iconUrl": dappItem.icon, + "topic": dappItem.url } if (!session.url) { @@ -777,6 +777,10 @@ WalletConnectSDKBase { 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); } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml index 601f2f8569..708b48b0ef 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml @@ -49,8 +49,8 @@ WalletConnectSDKBase { wcCalls.ping(topic) } - buildApprovedNamespaces: function(params, supportedNamespaces) { - wcCalls.buildApprovedNamespaces(params, supportedNamespaces) + buildApprovedNamespaces: function(id, params, supportedNamespaces) { + wcCalls.buildApprovedNamespaces(id, params, supportedNamespaces) } approveSession: function(sessionProposal, supportedNamespaces) { @@ -134,16 +134,16 @@ WalletConnectSDKBase { ) } - function buildApprovedNamespaces(params, supportedNamespaces) { - console.debug(`WC WalletConnectSDK.wcCall.buildApprovedNamespaces; params: ${JSON.stringify(params)}, supportedNamespaces: ${JSON.stringify(supportedNamespaces)}`) + function buildApprovedNamespaces(pairingId, params, supportedNamespaces) { + console.debug(`WC WalletConnectSDK.wcCall.buildApprovedNamespaces; id: ${pairingId}, params: ${JSON.stringify(params)}, supportedNamespaces: ${JSON.stringify(supportedNamespaces)}`) d.engine.runJavaScript(` wc.buildApprovedNamespaces(${JSON.stringify(params)}, ${JSON.stringify(supportedNamespaces)}) .then((approvedNamespaces) => { - wc.statusObject.onBuildApprovedNamespacesResponse(approvedNamespaces, "") + wc.statusObject.onBuildApprovedNamespacesResponse(${pairingId}, approvedNamespaces, "") }) .catch((e) => { - wc.statusObject.onBuildApprovedNamespacesResponse("", e.message) + wc.statusObject.onBuildApprovedNamespacesResponse(${pairingId}, "", e.message) }) ` ) @@ -155,10 +155,10 @@ WalletConnectSDKBase { d.engine.runJavaScript(` wc.approveSession(${JSON.stringify(sessionProposal)}, ${JSON.stringify(supportedNamespaces)}) .then((session) => { - wc.statusObject.onApproveSessionResponse(session, "") + wc.statusObject.onApproveSessionResponse(${sessionProposal.id}, session, "") }) .catch((e) => { - wc.statusObject.onApproveSessionResponse("", e.message) + wc.statusObject.onApproveSessionResponse(${sessionProposal.id}, "", e.message) }) ` ) @@ -170,10 +170,10 @@ WalletConnectSDKBase { d.engine.runJavaScript(` wc.rejectSession(${id}) .then((value) => { - wc.statusObject.onRejectSessionResponse("") + wc.statusObject.onRejectSessionResponse(${id}, "") }) .catch((e) => { - wc.statusObject.onRejectSessionResponse(e.message) + wc.statusObject.onRejectSessionResponse(${id}, e.message) }) ` ) @@ -296,19 +296,19 @@ WalletConnectSDKBase { console.debug(`WC WalletConnectSDK.onDisconnectPairingResponse; topic: ${topic}, error: ${error}`) } - function onBuildApprovedNamespacesResponse(approvedNamespaces, error) { - console.debug(`WC WalletConnectSDK.onBuildApprovedNamespacesResponse; approvedNamespaces: ${approvedNamespaces ? JSON.stringify(approvedNamespaces) : "-"}, error: ${error}`) - root.buildApprovedNamespacesResult(approvedNamespaces, error) + function onBuildApprovedNamespacesResponse(id, approvedNamespaces, error) { + console.debug(`WC WalletConnectSDK.onBuildApprovedNamespacesResponse; id: ${id}, approvedNamespaces: ${approvedNamespaces ? JSON.stringify(approvedNamespaces) : "-"}, error: ${error}`) + root.buildApprovedNamespacesResult(id, approvedNamespaces, error) } - function onApproveSessionResponse(session, error) { - console.debug(`WC WalletConnectSDK.onApproveSessionResponse; sessionTopic: ${JSON.stringify(session)}, error: ${error}`) - root.approveSessionResult(session, error) + function onApproveSessionResponse(proposalId, session, error) { + console.debug(`WC WalletConnectSDK.onApproveSessionResponse; proposalId: ${proposalId}, sessionTopic: ${JSON.stringify(session)}, error: ${error}`) + root.approveSessionResult(proposalId, session, error) } - function onRejectSessionResponse(error) { - console.debug(`WC WalletConnectSDK.onRejectSessionResponse; error: ${error}`) - root.rejectSessionResult(error) + function onRejectSessionResponse(proposalId, error) { + console.debug(`WC WalletConnectSDK.onRejectSessionResponse; proposalId: ${proposalId}, error: ${error}`) + root.rejectSessionResult(proposalId, error) } function onAcceptSessionRequestResponse(topic, id, error) { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml index 073ddc1678..f34f089475 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDKBase.qml @@ -9,9 +9,10 @@ Item { signal pairResponse(bool success) signal sessionProposal(var sessionProposal) signal sessionProposalExpired() - signal buildApprovedNamespacesResult(var session, string error) - signal approveSessionResult(var approvedNamespaces, string error) - signal rejectSessionResult(string error) + 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 sessionRequestEvent(var sessionRequest) signal sessionRequestUserAnswerResult(string topic, string id, bool accept /* not reject */, string error) @@ -41,7 +42,7 @@ Item { console.error("ping not implemented") } - property var buildApprovedNamespaces: function(params, supportedNamespaces) { + property var buildApprovedNamespaces: function(id, params, supportedNamespaces) { console.error("buildApprovedNamespaces not implemented") } property var approveSession: function(sessionProposal, supportedNamespaces) { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml index ec9de28352..90549e4950 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml @@ -28,235 +28,229 @@ import "types" QObject { id: root + //input properties required property WalletConnectSDKBase wcSDK required property DAppsStore store required property var walletRootStore - readonly property var dappsModel: ConcatModel { - markerRoleName: "source" - - sources: [ - SourceModel { - model: dappsProvider.dappsModel - markerRoleValue: "walletConnect" - }, - SourceModel { - model: connectorDAppsProvider.dappsModel - markerRoleValue: "connector" - } - ] - } - readonly property alias requestHandler: requestHandler - - readonly property bool isServiceAvailableForAddressSelection: dappsProvider.supportedAccountsModel.ModelCount.count - + //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: requestHandler.requestsModel + /// Model containing the valid accounts a dApp can interact with + readonly property var validAccounts: d.validAccounts + /// Model containing the networks a dApp can interact with + readonly property var flatNetworks: root.walletRootStore.filteredFlatModel + /// Service can interact with the current address selection + /// Default value: true + readonly property bool serviceAvailableToCurrentAddress: !root.walletRootStore.selectedAddress || + ModelUtils.contains(root.validAccounts, "address", root.walletRootStore.selectedAddress, Qt.CaseInsensitive) + /// TODO: refactor readonly property alias connectorDAppsProvider: connectorDAppsProvider - readonly property var validAccounts: SortFilterProxyModel { - sourceModel: d.supportedAccountsModel - proxyRoles: [ - FastExpressionRole { - name: "colorizedChainPrefixes" - function getChainShortNames(chainIds) { - const chainShortNames = root.walletRootStore.getNetworkShortNames(chainIds) - return WalletUtils.colorizedChainPrefix(chainShortNames) - } - expression: getChainShortNames(model.preferredSharingChainIds) - expectedRoles: ["preferredSharingChainIds"] - } - ] + // 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.authenticate(topic, id) } - readonly property var flatNetworks: root.walletRootStore.filteredFlatModel + function rejectSign(topic, id, hasError) { + requestHandler.rejectSessionRequest(topic, id, hasError) + } + + /// Validates the pairing URI 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 - } - - 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) - }); + d.validatePairingUri(uri) } + /// Initiates the pairing process with the given URI function pair(uri) { - d.acceptedSessionProposal = null timeoutTimer.start() wcSDK.pair(uri) } + + /// Approves or rejects the session proposal + function approvePairSession(key, approvedChainIds, accountAddress) { + if (!d.activeProposals.has(key)) { + console.error("No active proposal found for key: " + key) + return + } - function approvePairSession(sessionProposal, approvedChainIds, approvedAccount) { - d.acceptedSessionProposal = sessionProposal + const proposal = d.activeProposals.get(key) + d.acceptedSessionProposal = proposal const approvedNamespaces = JSON.parse( DAppsHelpers.buildSupportedNamespaces(approvedChainIds, - [approvedAccount.address], + [accountAddress], SessionRequest.getSupportedMethods()) ) - wcSDK.buildApprovedNamespaces(sessionProposal.params, approvedNamespaces) + wcSDK.buildApprovedNamespaces(key, proposal.params, approvedNamespaces) } + /// Rejects the session proposal function rejectPairSession(id) { wcSDK.rejectSession(id) } - function disconnectSession(sessionTopic) { - wcSDK.disconnectSession(sessionTopic) + /// 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) } - function disconnectDapp(url) { - wcSDK.getActiveSessions((allSessionsAllProfiles) => { - const sessions = DAppsHelpers.filterActiveSessionsForKnownAccounts(allSessionsAllProfiles, validAccounts) - let dappFoundInWcSessions = false - for (const sessionID in sessions) { - const session = sessions[sessionID] - const accountsInSession = DAppsHelpers.getAccountsInSession(session) - const dapp = session.peer.metadata - const topic = session.topic - if (dapp.url === url) { - if (!dappsProvider.selectedAddress || - (accountsInSession.includes(dappsProvider.selectedAddress))) - { - dappFoundInWcSessions = true - wcSDK.disconnectSession(topic) - } - } - } - - // TODO: #16044 - Refactor Wallet connect service to handle multiple SDKs - if (!dappFoundInWcSessions) { - // Revoke browser plugin session - root.revokeSession(url) - d.notifyDappDisconnect(url, false) - } - }); - } - - function getDApp(dAppUrl) { - return ModelUtils.getByKey(dappsModel, "url", dAppUrl); - } - - signal connectDApp(var dappChains, var sessionProposal, var approvedNamespaces) - signal approveSessionResult(var session, var error) - signal sessionRequest(SessionRequestResolved request) + // signals + signal connectDApp(var dappChains, url dappUrl, string dappName, url dappIcon, var key) + // Emitted as a response to WalletConnectService.approveSession + // @param key The key of the session proposal + // @param error The error message + // @param topic The new topic of the session + signal approveSessionResult(var key, var error, var topic) + // Emitted when a new session is requested by a dApp + signal sessionRequest(string id) signal displayToastMessage(string message, bool error) // Emitted as a response to WalletConnectService.validatePairingUri or other WalletConnectService.pair // and WalletConnectService.approvePair errors signal pairingValidated(int validationState) - - signal revokeSession(string dAppUrl) - - readonly property Connections sdkConnections: Connections { - target: wcSDK - - function onPairResponse(ok) { - if (!ok) { - d.reportPairErrorState(Pairing.errors.unknownError) - } // else waiting for onSessionProposal - } - - function onSessionProposal(sessionProposal) { - d.currentSessionProposal = sessionProposal - - const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels( - root.flatNetworks, root.validAccounts, SessionRequest.getSupportedMethods()) - wcSDK.buildApprovedNamespaces(sessionProposal.params, JSON.parse(supportedNamespacesStr)) - } - - function onBuildApprovedNamespacesResult(approvedNamespaces, error) { - if(error || !approvedNamespaces) { - // Check that it contains Non conforming namespaces" - if (error.includes("Non conforming namespaces")) { - d.reportPairErrorState(Pairing.errors.unsupportedNetwork) - } else { - d.reportPairErrorState(Pairing.errors.unknownError) - } - return - } - const an = approvedNamespaces.eip155 - if (!(an.accounts) || an.accounts.length === 0 || (!(an.chains) || an.chains.length === 0)) { - d.reportPairErrorState(Pairing.errors.unsupportedNetwork) - return - } - - if (d.acceptedSessionProposal) { - wcSDK.approveSession(d.acceptedSessionProposal, approvedNamespaces) - } else { - const res = DAppsHelpers.extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces) - - root.connectDApp(res.chains, d.currentSessionProposal, approvedNamespaces) - } - } - - function onApproveSessionResult(session, err) { - if (err) { - d.reportPairErrorState(Pairing.errors.unknownError) - return - } - - // TODO #14754: implement custom dApp notification - const app_url = d.currentSessionProposal ? d.currentSessionProposal.params.proposer.metadata.url : "-" - const app_domain = StringUtils.extractDomainFromLink(app_url) - root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_domain), false) - - // Persist session - if(!store.addWalletConnectSession(JSON.stringify(session))) { - console.error("Failed to persist session") - } - - // Notify client - root.approveSessionResult(session, err) - - dappsProvider.updateDapps() - } - - function onRejectSessionResult(err) { - const app_url = d.currentSessionProposal ? d.currentSessionProposal.params.proposer.metadata.url : "-" - const app_domain = StringUtils.extractDomainFromLink(app_url) - if(err) { - d.reportPairErrorState(Pairing.errors.unknownError) - root.displayToastMessage(qsTr("Failed to reject connection request for %1").arg(app_domain), true) - } else { - root.displayToastMessage(qsTr("Connection request for %1 was rejected").arg(app_domain), false) - } - } - - function onSessionDelete(topic, err) { - d.disconnectSessionRequested(topic, err) - } - } + signal revokeSession(string topic) QObject { id: d - readonly property var supportedAccountsModel: SortFilterProxyModel { + readonly property var validAccounts: SortFilterProxyModel { sourceModel: root.walletRootStore.nonWatchAccounts + proxyRoles: [ + FastExpressionRole { + name: "colorizedChainPrefixes" + function getChainShortNames(chainIds) { + const chainShortNames = root.walletRootStore.getNetworkShortNames(chainIds) + return WalletUtils.colorizedChainPrefix(chainShortNames) + } + expression: getChainShortNames(model.preferredSharingChainIds) + expectedRoles: ["preferredSharingChainIds"] + } + ] } - property var currentSessionProposal: null + readonly property var dappsModel: ConcatModel { + id: dappsModel + markerRoleName: "source" + + sources: [ + SourceModel { + model: dappsProvider.dappsModel + markerRoleValue: "walletConnect" + }, + SourceModel { + model: connectorDAppsProvider.dappsModel + markerRoleValue: "statusConnect" + } + ] + } + + readonly property var filteredDappsModel: SortFilterProxyModel { + id: dappsFilteredModel + objectName: "DAppsModelFiltered" + sourceModel: d.dappsModel + readonly property string selectedAddress: root.walletRootStore.selectedAddress + + filters: FastExpressionFilter { + enabled: !!dappsFilteredModel.selectedAddress + + function isAddressIncluded(accountAddressesSubModel, selectedAddress) { + if (!accountAddressesSubModel) { + return false + } + const addresses = ModelUtils.modelToFlatArray(accountAddressesSubModel, "address") + return addresses.includes(selectedAddress) + } + expression: isAddressIncluded(model.accountAddresses, dappsFilteredModel.selectedAddress) + + expectedRoles: "accountAddresses" + } + } + + property var activeProposals: new Map() // key: proposalId, value: sessionProposal property var acceptedSessionProposal: null + /// Disconnects the WC session with the given topic + function disconnectSession(sessionTopic) { + wcSDK.disconnectSession(sessionTopic) + } + + function disconnectDapp(topic) { + const dApp = d.getDAppByTopic(topic) + if (!dApp) { + console.error("Disconnecting dApp: dApp not found") + return + } + + if (!dApp.connectorId == undefined) { + console.error("Disconnecting dApp: connectorId not found") + return + } + + // TODO: refactor + if (dApp.connectorId === connectorDAppsProvider.connectorId) { + root.revokeSession(topic) + d.notifyDappDisconnect(dApp.url, false) + return + } + // TODO: refactor + if (dApp.connectorId === dappsProvider.connectorId) { + // Currently disconnect acts on all sessions! + for (let i = 0; i < dApp.sessions.ModelCount.count; i++) { + d.disconnectSession(dApp.sessions.get(i).topic) + } + } + } + + 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 + } + + 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) + }); + } + + function authenticate(topic, id) { + const request = sessionRequestsModel.findRequest(topic, id) + if (!request) { + console.error("Session request not found") + return + } + requestHandler.authenticate(topic, id, request.accountAddress, request.feesInfo) + } + function reportPairErrorState(state) { timeoutTimer.stop() root.pairingValidated(state) @@ -309,6 +303,137 @@ QObject { root.displayToastMessage(qsTr("Disconnected from %1").arg(appDomain), false) } } + + function getDAppByTopic(topic) { + return ModelUtils.getFirstModelEntryIf(d.dappsModel, (modelItem) => { + if (modelItem.topic == topic) { + return true + } + if (!modelItem.sessions) { + return false + } + for (let i = 0; i < modelItem.sessions.ModelCount.count; i++) { + if (modelItem.sessions.get(i).topic == topic) { + return true + } + } + }) + } + } + + Connections { + target: wcSDK + + function onPairResponse(ok) { + if (!ok) { + d.reportPairErrorState(Pairing.errors.unknownError) + } // else waiting for onSessionProposal + } + + function onSessionProposal(sessionProposal) { + const key = sessionProposal.id + d.activeProposals.set(key, sessionProposal) + + const supportedNamespacesStr = DAppsHelpers.buildSupportedNamespacesFromModels( + root.flatNetworks, root.validAccounts, SessionRequest.getSupportedMethods()) + wcSDK.buildApprovedNamespaces(key, sessionProposal.params, JSON.parse(supportedNamespacesStr)) + } + + function onBuildApprovedNamespacesResult(key, approvedNamespaces, error) { + if (!d.activeProposals.has(key)) { + console.error("No active proposal found for key: " + key) + return + } + + if(error || !approvedNamespaces) { + // Check that it contains Non conforming namespaces" + if (error.includes("Non conforming namespaces")) { + d.reportPairErrorState(Pairing.errors.unsupportedNetwork) + } else { + d.reportPairErrorState(Pairing.errors.unknownError) + } + return + } + const an = approvedNamespaces.eip155 + if (!(an.accounts) || an.accounts.length === 0 || (!(an.chains) || an.chains.length === 0)) { + d.reportPairErrorState(Pairing.errors.unsupportedNetwork) + return + } + + if (d.acceptedSessionProposal) { + wcSDK.approveSession(d.acceptedSessionProposal, approvedNamespaces) + } else { + const proposal = d.activeProposals.get(key) + const res = DAppsHelpers.extractChainsAndAccountsFromApprovedNamespaces(approvedNamespaces) + const chains = res.chains + const dAppUrl = proposal.params.proposer.metadata.url + const dAppName = proposal.params.proposer.metadata.name + const dAppIcons = proposal.params.proposer.metadata.icons + const dAppIcon = dAppIcons && dAppIcons.length > 0 ? dAppIcons[0] : "" + + root.connectDApp(chains, dAppUrl, dAppName, dAppIcon, key) + } + } + + function onApproveSessionResult(proposalId, session, err) { + if (!d.activeProposals.has(proposalId)) { + console.error("No active proposal found for key: " + proposalId) + return + } + + if (!d.acceptedSessionProposal || d.acceptedSessionProposal.id !== proposalId) { + console.error("No accepted proposal found for key: " + proposalId) + d.activeProposals.delete(proposalId) + return + } + + const proposal = d.activeProposals.get(proposalId) + d.activeProposals.delete(proposalId) + d.acceptedSessionProposal = null + + if (err) { + d.reportPairErrorState(Pairing.errors.unknownError) + return + } + + // TODO #14754: implement custom dApp notification + const app_url = proposal.params.proposer.metadata.url ?? "-" + const app_domain = StringUtils.extractDomainFromLink(app_url) + root.displayToastMessage(qsTr("Connected to %1 via WalletConnect").arg(app_domain), false) + + // Persist session + if(!store.addWalletConnectSession(JSON.stringify(session))) { + console.error("Failed to persist session") + } + + // Notify client + root.approveSessionResult(proposalId, err, session.topic) + + dappsProvider.updateDapps() + } + + function onRejectSessionResult(proposalId, err) { + if (!d.activeProposals.has(proposalId)) { + console.error("No active proposal found for key: " + proposalId) + return + } + + const proposal = d.activeProposals.get(proposalId) + d.activeProposals.delete(proposalId) + + const app_url = proposal.params.proposer.metadata.url ?? "-" + const app_domain = StringUtils.extractDomainFromLink(app_url) + if(err) { + d.reportPairErrorState(Pairing.errors.unknownError) + root.displayToastMessage(qsTr("Failed to reject connection request for %1").arg(app_domain), true) + } else { + root.displayToastMessage(qsTr("Connection request for %1 was rejected").arg(app_domain), false) + } + } + + function onSessionDelete(topic, err) { + d.disconnectSessionRequested(topic, err) + } } Component.onCompleted: { @@ -325,9 +450,9 @@ QObject { currenciesStore: root.walletRootStore.currencyStore assetsStore: root.walletRootStore.walletAssetsStore - onSessionRequest: (request) => { + onSessionRequest: (id) => { timeoutTimer.stop() - root.sessionRequest(request) + root.sessionRequest(id) } onDisplayToastMessage: (message, error) => { root.displayToastMessage(message, error) @@ -336,11 +461,9 @@ QObject { DAppsListProvider { id: dappsProvider - sdk: root.wcSDK store: root.store - supportedAccountsModel: d.supportedAccountsModel - selectedAddress: root.walletRootStore.selectedAddress + supportedAccountsModel: root.walletRootStore.nonWatchAccounts } ConnectorDAppsListProvider { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml index a5005414c5..62a676ce40 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml @@ -15,11 +15,14 @@ QObject { /// } required property var event + /// dApp request data required property string topic required property string id required property string method required property string accountAddress required property string chainId + // Maps to Constants.DAppConnectors values + required property int sourceId required property var data // Data prepared for display in a human readable format @@ -29,9 +32,18 @@ QObject { readonly property alias dappUrl: d.dappUrl readonly property alias dappIcon: d.dappIcon + /// extra data resolved from wallet property string maxFeesText: "" property string maxFeesEthText: "" - property bool enoughFunds: false + property bool haveEnoughFunds: false + property bool haveEnoughFees: false + + property var /* Big */ fiatMaxFees + property var /* Big */ ethMaxFees + property var feesInfo + + /// maps to Constants.TransactionEstimatedTime values + property int estimatedTimeCategory: 0 function resolveDappInfoFromSession(session) { let meta = session.peer.metadata diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestsModel.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestsModel.qml index 05130d38de..d12031cc2c 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestsModel.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestsModel.qml @@ -5,14 +5,14 @@ ListModel { id: root function enqueue(request) { - root.append(request); + root.append({requestId: request.id, requestItem: request}); } function dequeue() { if (root.count > 0) { var item = root.get(0); root.remove(0); - return item; + return item.requestItem; } return null; } @@ -20,8 +20,19 @@ ListModel { /// returns null if not found function findRequest(topic, id) { for (var i = 0; i < root.count; i++) { - let entry = root.get(i) - if (entry.topic === topic && entry.id === id) { + let entry = root.get(i).requestItem + if (entry.topic == topic && entry.id == id) { + return entry; + } + } + return null; + } + + // returns null if not found + function findById(id) { + for (var i = 0; i < root.count; i++) { + let entry = root.get(i).requestItem + if (entry.id == id) { return entry; } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir index ae2bf029da..93a107d050 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir @@ -1,3 +1,4 @@ SessionRequestResolved 1.0 SessionRequestResolved.qml +SessionRequestsModel 1.0 SessionRequestsModel.qml singleton SessionRequest 1.0 SessionRequest.qml singleton Pairing 1.0 Pairing.qml \ No newline at end of file diff --git a/ui/imports/shared/popups/walletconnect/ConnectDAppModal.qml b/ui/imports/shared/popups/walletconnect/ConnectDAppModal.qml index 7e5a8e346e..6a69984a53 100644 --- a/ui/imports/shared/popups/walletconnect/ConnectDAppModal.qml +++ b/ui/imports/shared/popups/walletconnect/ConnectDAppModal.qml @@ -79,10 +79,10 @@ StatusDialog { readonly property int connectionSuccessfulStatus: 1 readonly property int connectionFailedStatus: 2 - function pairSuccessful(session) { + function pairSuccessful() { d.connectionStatus = root.connectionSuccessfulStatus } - function pairFailed(session, err) { + function pairFailed() { d.connectionStatus = root.connectionFailedStatus } diff --git a/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml b/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml index d898d481ce..32d07cd00f 100644 --- a/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml +++ b/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml @@ -41,7 +41,7 @@ SignTransactionModalBase { property bool enoughFundsForTransaction: true property bool enoughFundsForFees: false - signButtonEnabled: enoughFundsForTransaction && enoughFundsForFees + signButtonEnabled: (!hasFees) || enoughFundsForTransaction && enoughFundsForFees title: qsTr("Sign Request") subtitle: SQUtils.StringUtils.extractDomainFromLink(root.dappUrl) headerIconComponent: RoundImageWithBadge { @@ -68,7 +68,7 @@ SignTransactionModalBase { infoTag.states: [ State { name: "insufficientFunds" - when: !root.enoughFundsForTransaction + when: root.hasFees && !root.enoughFundsForTransaction PropertyChanges { target: infoTag asset.color: Theme.palette.dangerColor1 @@ -96,8 +96,8 @@ SignTransactionModalBase { Layout.fillWidth: true objectName: "footerFiatFeesText" text: formatBigNumber(root.fiatFees, root.currentCurrency) - loading: root.feesLoading - customColor: root.enoughFundsForFees ? Theme.palette.directColor1 : Theme.palette.dangerColor1 + loading: root.feesLoading && root.hasFees + customColor: !root.hasFees || root.enoughFundsForFees ? Theme.palette.directColor1 : Theme.palette.dangerColor1 elide: Qt.ElideMiddle Binding on text { when: !root.hasFees diff --git a/ui/imports/shared/popups/walletconnect/PairWCModal/WCUriInput.qml b/ui/imports/shared/popups/walletconnect/PairWCModal/WCUriInput.qml index a8bb80a9a6..645151b3c4 100644 --- a/ui/imports/shared/popups/walletconnect/PairWCModal/WCUriInput.qml +++ b/ui/imports/shared/popups/walletconnect/PairWCModal/WCUriInput.qml @@ -22,11 +22,14 @@ ColumnLayout { StatusBaseInput { id: input + Component.onCompleted: { + forceActiveFocus() + } + Layout.fillWidth: true Layout.preferredHeight: 132 placeholderText: qsTr("Paste URI") - verticalAlignment: TextInput.AlignTop valid: { diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml index ce106b0c64..c79045bfc6 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -1464,4 +1464,9 @@ QtObject { } readonly property string navigationMetric: "navigation" + + enum DAppConnectors { + WalletConnect = 1, + StatusConnect = 2 + } }