diff --git a/storybook/pages/DAppsWorkflowPage.qml b/storybook/pages/DAppsWorkflowPage.qml index 2f592e9adb..56ae94df02 100644 --- a/storybook/pages/DAppsWorkflowPage.qml +++ b/storybook/pages/DAppsWorkflowPage.qml @@ -72,6 +72,7 @@ Item { accountsModel: wcService.validAccounts networksModel: wcService.flatNetworks sessionRequestsModel: wcService.sessionRequestsModel + enabled: wcService.isServiceOnline //formatBigNumber: (number, symbol, noSymbolOption) => wcService.walletRootStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption) @@ -200,6 +201,13 @@ Item { } } + StatusBaseText { text: "Networks Down" } + + NetworkFilter { + id: networkFilter + flatNetworks: walletConnectService.walletRootStore.filteredFlatModel + } + // spacer ColumnLayout {} @@ -337,6 +345,8 @@ Item { projectId: projectIdText.projectId } + blockchainNetworksDown: networkFilter.selection + store: SharedStores.DAppsStore { signal dappsListReceived(string dappsJson) signal userAuthenticated(string topic, string id, string password, string pin) diff --git a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml index da7aa86b82..6ddf86ed70 100644 --- a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml +++ b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml @@ -121,6 +121,7 @@ Item { onPairingValidated: function(validationState) { onPairingValidatedTriggers.push({validationState}) } + blockchainNetworksDown: [] } } @@ -221,6 +222,33 @@ Item { } } + readonly property ListModel filteredFlatModelWithOnlineStat: ListModel { + ListElement { + chainId: 1 + layer: 1 + isOnline: true + } + ListElement { + chainId: 2 + chainName: "Test Chain" + iconUrl: "network/Network=Ethereum" + layer: 2 + isOnline: true + } + // Used by tst_balanceCheck + ListElement { + chainId: 11155111 + layer: 1 + isOnline: true + } + // Used by tst_balanceCheck + ListElement { + chainId: 421613 + layer: 2 + isOnline: true + } + } + readonly property ListModel nonWatchAccounts: ListModel { ListElement { address: "0x1" @@ -299,9 +327,10 @@ Item { sdk: sdk, store: store, accountsModel: walletStore.nonWatchAccounts, - networksModel: walletStore.filteredFlatModel + networksModel: walletStore.filteredFlatModelWithOnlineStat }) verify(!!handler) + sdk.getActiveSessionsCallbacks = [] } function cleanup() { @@ -791,6 +820,7 @@ Item { verify(!!walletStore) provider = createTemporaryObject(dappsListProviderComponent, root, {sdk: sdk, store: store, supportedAccountsModel: walletStore.nonWatchAccounts}) verify(!!provider) + sdk.getActiveSessionsCallbacks = [] } function cleanup() { diff --git a/ui/app/AppLayouts/Wallet/helpers/ChainsAvailabilityWatchdog.qml b/ui/app/AppLayouts/Wallet/helpers/ChainsAvailabilityWatchdog.qml new file mode 100644 index 0000000000..d8d00f1038 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/helpers/ChainsAvailabilityWatchdog.qml @@ -0,0 +1,56 @@ +import QtQuick 2.15 + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 + +// This component receives the networksModel +// and breaks down the online status of each chain +QObject { + id: root + // Required roleNames: chainId, isOnline + required property var networksModel + + readonly property bool allOffline: d.allOffline + readonly property bool allOnline: d.allOnline + + property bool active: true + + signal chainOnlineChanged(int chainId, bool isOnline) + + // Network online observer + // `chainOnlineChanged` signal when the online status of a chain changes + Instantiator { + id: networkOnlineObserver + model: root.networksModel + active: root.active + delegate: QtObject { + required property var model + property var /*var intended*/ isOnline: model.isOnline + + onIsOnlineChanged: { + root.chainOnlineChanged(model.chainId, isOnline) + } + } + } + + // Aggregator to count the number of online chains + SumAggregator{ + id: aggregator + model: root.active ? root.networksModel : null + roleName: "isOnline" + } + + // Network checker to check if the device is online + NetworkChecker { + id: networkChecker + active: root.active + } + + QtObject { + id: d + readonly property bool allOffline: !networkChecker.isOnline || aggregator.value === 0 + readonly property bool allOnline: networkChecker.isOnline && + aggregator.value > 0 && + aggregator.value === networksModel.ModelCount.count + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/helpers/qmldir b/ui/app/AppLayouts/Wallet/helpers/qmldir index 9439fa1565..7d0653bfdc 100644 --- a/ui/app/AppLayouts/Wallet/helpers/qmldir +++ b/ui/app/AppLayouts/Wallet/helpers/qmldir @@ -2,3 +2,4 @@ ChartDataBase 1.0 ChartDataBase.qml TokenBalanceHistoryData 1.0 TokenBalanceHistoryData.qml TokenMarketValuesData 1.0 TokenMarketValuesData.qml singleton NetworkModelHelpers 1.0 NetworkModelHelpers.qml +ChainsAvailabilityWatchdog 1.0 ChainsAvailabilityWatchdog.qml diff --git a/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml b/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml index a01de9fb77..147e701427 100644 --- a/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml +++ b/ui/app/AppLayouts/Wallet/panels/WalletHeader.qml @@ -146,7 +146,7 @@ Item { visible: !root.walletStore.showSavedAddresses && root.dappsEnabled && wcService.serviceAvailableToCurrentAddress - enabled: !!Global.walletConnectService + enabled: !!wcService && wcService.isServiceOnline loginType: root.loginType diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml index 0951fa018e..543ef0deca 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsRequestHandler.qml @@ -16,11 +16,13 @@ SQUtils.QObject { required property WalletConnectSDKBase sdk required property DAppsStore store required property var accountsModel + // Required roles: chainId, layer, isOnline required property var networksModel required property CurrenciesStore currenciesStore required property WalletStore.WalletAssetsStore assetsStore property alias requestsModel: requests + readonly property bool isServiceOnline: chainsSupervisorPlugin.anyChainAvailable && sdk.sdkReady function subscribeForFeeUpdates(topic, id) { d.subscribeForFeeUpdates(topic, id) @@ -1175,4 +1177,11 @@ SQUtils.QObject { } } } + + ChainsSupervisorPlugin { + id: chainsSupervisorPlugin + + sdk: root.sdk + networksModel: root.networksModel + } } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml index 8f7ddc7259..ee46359e56 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectSDK.qml @@ -364,14 +364,6 @@ WalletConnectSDKBase { ` ) } - - function connected() { - console.debug(`WC WalletConnectSDK.wcCall.connected;`) - } - - function disconnected() { - console.debug(`WC WalletConnectSDK.wcCall.disconnected;`) - } } QtObject { diff --git a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml index 901e3eb4c5..32342c3804 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/WalletConnectService.qml @@ -32,6 +32,8 @@ QObject { required property WalletConnectSDKBase wcSDK required property DAppsStore store required property var walletRootStore + // // Array[chainId] of the networks that are down + required property var blockchainNetworksDown //output properties /// Model contaning all dApps available for the currently selected account @@ -49,6 +51,8 @@ QObject { /// TODO: refactor readonly property alias connectorDAppsProvider: connectorDAppsProvider + readonly property bool isServiceOnline: requestHandler.isServiceOnline + // methods /// Triggers the signing process for the given session request /// @param topic The topic of the session @@ -250,7 +254,16 @@ QObject { sdk: root.wcSDK store: root.store accountsModel: root.validAccounts - networksModel: root.flatNetworks + networksModel: SortFilterProxyModel { + sourceModel: root.flatNetworks + proxyRoles: [ + FastExpressionRole { + name: "isOnline" + expression: !root.blockchainNetworksDown.map(Number).includes(model.chainId) + expectedRoles: "chainId" + } + ] + } currenciesStore: root.walletRootStore.currencyStore assetsStore: root.walletRootStore.walletAssetsStore diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/ChainsSupervisorPlugin.qml b/ui/app/AppLayouts/Wallet/services/dapps/plugins/ChainsSupervisorPlugin.qml new file mode 100644 index 0000000000..4a55135092 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/ChainsSupervisorPlugin.qml @@ -0,0 +1,150 @@ +import QtQuick 2.15 + +import AppLayouts.Wallet.helpers 1.0 +import AppLayouts.Wallet.services.dapps 1.0 +import AppLayouts.Wallet.services.dapps.types 1.0 as DAppsTypes + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 + +// This plugin handles the chain ability for the dapps +// It monitors the chain availability and updates the dapps accordingly +// When all chains are offline, it will inform each session that the chains are offline +// When a chain comes back online, it will check if there are any active sessions and inform them +// See [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) +QObject { + id: root + + required property WalletConnectSDKBase sdk + // Required roles: chainId, isOnline + required property var networksModel + // Happens when any chain is available + readonly property bool anyChainAvailable: !chainsAvailabilityWatchdog.allOffline + + ChainsAvailabilityWatchdog { + id: chainsAvailabilityWatchdog + + // property used for spam protection + property var lastNotifiedCStatuses: new Map() + networksModel: root.networksModel + + onChainOnlineChanged: (chainId, isOnline) => { + if (!isOnline || !sdk.sdkReady) { + return + } + + // Spam protection. Spamming the SDK could result to blocks + if (lastNotifiedCStatuses.has(chainId) && lastNotifiedCStatuses.get(chainId) === isOnline) { + return + } + + lastNotifiedCStatuses.set(chainId, isOnline) + d.notifyChainConnected(chainId) + } + + onAllOfflineChanged: { + if(!sdk.sdkReady) { + return + } + + if (allOffline) { + lastNotifiedCStatuses.clear() + d.notifyAllChainsDisconnected() + } + } + } + + QtObject { + id: d + + function sessionHasEventSupport(session, event) { + try { + if (!event) { + return false + } + + if (session.namespaces.eip155.events.includes(event)) { + return true + } + + return false + } catch (e) { + console.error("Error checking sessionHasEventSupport: ", e) + return false + } + } + + function sessionHasChainSupport(session, chainId) { + try { + if (!chainId) { + return false + } + + let chainIds = session.namespaces.eip155.chains.map(DAppsHelpers.chainIdFromEip155); + if (!chainIds.includes(chainId)) { + return false + } + + let accounts = session.namespaces.eip155.accounts + let chainAccounts = accounts.map((account) => account.startsWith(`eip155:${chainId}`)) + if (!chainAccounts.includes(true)) { + return false + } + + return true + } catch (e) { + console.error("Error checking sessionHasChainSupport: ", e) + return false + } + } + + function notifyAllChainsDisconnected() { + sdk.getActiveSessions((allSessions) => { + Object.values(allSessions).forEach((session) => { + if (!d.sessionHasEventSupport(session, "disconnect")) { + return + } + + try { + session.namespaces.eip155.chains.forEach((chainId) => { + const chainInt = DAppsHelpers.chainIdFromEip155(chainId) + if (!d.sessionHasChainSupport(session, chainInt)) { + return + } + + sdk.emitSessionEvent(session.topic, { + name: "disconnect", + data: { + code: DAppsTypes.ErrorCodes.rpcErrors.disconnected, + } + }, chainId) + }) + } catch (e) { + console.error("Error emitting session event: ", e) + } + }) + }) + } + + function notifyChainConnected(chainId) { + sdk.getActiveSessions((sessions) => { + Object.values(sessions).forEach((session) => { + if (!d.sessionHasEventSupport(session, "connect")) { + return + } + if (!d.sessionHasChainSupport(session, chainId)) { + return + } + + const hexChain = `0x${chainId.toString(16)}` + sdk.emitSessionEvent(session.topic, { + name: "connect", + data: { + chainId: hexChain, + } + }, `eip155:${chainId}`) + }) + }) + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir b/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir index 62f3bf0b52..b9a33432d0 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/qmldir @@ -1,2 +1,3 @@ +ChainsSupervisorPlugin 1.0 ChainsSupervisorPlugin.qml SiweRequestPlugin 1.0 SiweRequestPlugin.qml SiweLifeCycle 1.0 SiweLifeCycle.qml \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/ErrorCodes.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/ErrorCodes.qml new file mode 100644 index 0000000000..051e860e01 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/ErrorCodes.qml @@ -0,0 +1,13 @@ +pragma Singleton +import QtQuick 2.15 + +QtObject { + // Maps to https://eips.ethereum.org/EIPS/eip-1193#rpc-errors + readonly property QtObject rpcErrors: QtObject { + readonly property int userRejectedRequest: 4001 + readonly property int unauthorized: 4100 + readonly property int unsupportedMethod: 4200 + readonly property int disconnected: 4900 + readonly property int chainDisconnected: 4901 + } +} \ 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 9860129909..d5b4409b43 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/qmldir @@ -2,4 +2,5 @@ SessionRequestResolved 1.0 SessionRequestResolved.qml SessionRequestWithAuth 1.0 SessionRequestWithAuth.qml SessionRequestsModel 1.0 SessionRequestsModel.qml singleton SessionRequest 1.0 SessionRequest.qml -singleton Pairing 1.0 Pairing.qml \ No newline at end of file +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 e373822fd3..16e0ef8c6c 100644 --- a/ui/app/mainui/AppMain.qml +++ b/ui/app/mainui/AppMain.qml @@ -2212,6 +2212,7 @@ Item { controller: WalletStores.RootStore.walletConnectController } walletRootStore: WalletStores.RootStore + blockchainNetworksDown: appMain.networkConnectionStore.blockchainNetworksDown Component.onCompleted: { Global.walletConnectService = walletConnectService