From f42f342731f4596dc037e301f7b4cab0c530cf93 Mon Sep 17 00:00:00 2001 From: Alex Jbanca Date: Mon, 18 Nov 2024 15:20:10 +0200 Subject: [PATCH] fix(Dapps): Fixing fees in transaction requests Fixes: 1. Fixing the laggy scrolling on transaction requiests popups. The root cause of this issue was the fees request and also the estimated time request. These periodic requests were blocking. Now we'll call these API async. 2. Fixing the max fees: The fees computation was using 21k as gasLimit. This value was hardcoded in WC. Now we're requesting the gasLimit if it's not provided by the dApp. This call is also async. 3. Fixing the periodicity of the fees computation. The fees were computed by the client only if the tx object didn't already provide the fees. But the tx could fail if when the fees are highly volatile because it was not being overridden. Now Status is computing the fees periodically for all tx requests. 4. Fixing an issue where the loading state of the fees text in the modal was showing text underneath the loading animation. Fixed by updating the AnimatedText to support a custom target property. The text component used for session requests is using `cusomColor` property to set the text color and the `color` for the text must not be overriden. --- .../modules/main/wallet_section/module.nim | 3 +- .../wallet_connect/controller.nim | 38 +- .../service/wallet_connect/async_tasks.nim | 91 ++++ .../service/wallet_connect/service.nim | 177 ++++--- storybook/pages/DAppSignRequestModalPage.qml | 5 +- storybook/pages/DAppsWorkflowPage.qml | 36 +- .../qmlTests/tests/tst_DAppsWorkflow.qml | 30 +- .../qmlTests/tests/tst_SignRequestPlugin.qml | 5 +- .../Wallet/panels/DAppsWorkflow.qml | 1 + .../Wallet/services/dapps/DAppsModule.qml | 12 +- .../dapps/internal/TransactionFeesBroker.qml | 155 +++++++ .../internal/TransactionFeesSubscriber.qml | 134 ++++++ .../dapps/plugins/SignRequestPlugin.qml | 438 ++++-------------- .../services/dapps/types/SessionRequest.qml | 36 +- .../dapps/types/SessionRequestResolved.qml | 2 + ui/imports/shared/panels/AnimatedText.qml | 5 +- .../walletconnect/DAppSignRequestModal.qml | 30 +- ui/imports/shared/stores/DAppsStore.qml | 41 +- 18 files changed, 777 insertions(+), 462 deletions(-) create mode 100644 src/app_service/service/wallet_connect/async_tasks.nim create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesBroker.qml create mode 100644 ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesSubscriber.qml diff --git a/src/app/modules/main/wallet_section/module.nim b/src/app/modules/main/wallet_section/module.nim index c136e3799d..779401598d 100644 --- a/src/app/modules/main/wallet_section/module.nim +++ b/src/app/modules/main/wallet_section/module.nim @@ -176,7 +176,7 @@ proc newModule*( result.filter = initFilter(result.controller) result.walletConnectService = wc_service.newService(result.events, result.threadpool, settingsService, transactionService, keycardService) - result.walletConnectController = wc_controller.newController(result.walletConnectService, walletAccountService) + result.walletConnectController = wc_controller.newController(result.walletConnectService, walletAccountService, result.events) result.dappsConnectorService = connector_service.newService(result.events) result.dappsConnectorController = connector_controller.newController(result.dappsConnectorService, result.events) @@ -360,6 +360,7 @@ method load*(self: Module) = self.sendModule.load() self.networksModule.load() self.walletConnectService.init() + self.walletConnectController.init() self.dappsConnectorService.init() method isLoaded*(self: Module): bool = diff --git a/src/app/modules/shared_modules/wallet_connect/controller.nim b/src/app/modules/shared_modules/wallet_connect/controller.nim index 5913504b75..2c0d291f4a 100644 --- a/src/app/modules/shared_modules/wallet_connect/controller.nim +++ b/src/app/modules/shared_modules/wallet_connect/controller.nim @@ -1,6 +1,7 @@ import NimQml import chronicles, times, json +import app/core/eventemitter import app/global/global_singleton import app_service/common/utils import app_service/service/wallet_connect/service as wallet_connect_service @@ -19,18 +20,40 @@ QtObject: Controller* = ref object of QObject service: wallet_connect_service.Service walletAccountService: wallet_account_service.Service + events: EventEmitter proc delete*(self: Controller) = self.QObject.delete - proc newController*(service: wallet_connect_service.Service, walletAccountService: wallet_account_service.Service): Controller = + proc newController*( + service: wallet_connect_service.Service, + walletAccountService: wallet_account_service.Service, + events: EventEmitter): Controller = new(result, delete) result.service = service result.walletAccountService = walletAccountService + result.events = events result.QObject.setup + proc estimatedTimeResponse*(self: Controller, topic: string, estimatedTime: int) {.signal.} + proc suggestedFeesResponse*(self: Controller, topic: string, suggestedFeesJson: string) {.signal.} + proc estimatedGasResponse*(self: Controller, topic: string, gasEstimate: string) {.signal.} + + proc init*(self: Controller) = + self.events.on(SIGNAL_ESTIMATED_TIME_RESPONSE) do(e: Args): + let args = EstimatedTimeArgs(e) + self.estimatedTimeResponse(args.topic, args.estimatedTime) + + self.events.on(SIGNAL_SUGGESTED_FEES_RESPONSE) do(e: Args): + let args = SuggestedFeesArgs(e) + self.suggestedFeesResponse(args.topic, $(args.suggestedFees)) + + self.events.on(SIGNAL_ESTIMATED_GAS_RESPONSE) do(e: Args): + let args = EstimatedGasArgs(e) + self.estimatedGasResponse(args.topic, args.estimatedGas) + ## signals emitted by this controller proc userAuthenticationResult*(self: Controller, topic: string, id: string, error: bool, password: string, pin: string, payload: string) {.signal.} proc signingResultReceived*(self: Controller, topic: string, id: string, data: string) {.signal.} @@ -195,12 +218,15 @@ QtObject: error "sendTransaction failed: ", msg=e.msg self.signingResultReceived(topic, id, res) - proc getEstimatedTime(self: Controller, chainId: int, maxFeePerGasHex: string): int {.slot.} = - return self.service.getEstimatedTime(chainId, maxFeePerGasHex).int + proc requestEstimatedTime(self: Controller, topic: string, chainId: int, maxFeePerGasHex: string) {.slot.} = + self.service.getEstimatedTime(topic, chainId, maxFeePerGasHex) - proc getSuggestedFeesJson(self: Controller, chainId: int): string {.slot.} = - let dto = self.service.getSuggestedFees(chainId) - return dto.toJson() + proc requestSuggestedFeesJson(self: Controller, topic: string, chainId: int) {.slot.} = + self.service.requestSuggestedFees(topic, chainId) + + proc requestGasEstimate(self: Controller, topic: string, chainId: int, txJson: string) {.slot.} = + let txObj = parseJson(txJson) + self.service.requestGasEstimate(topic, txObj, chainId) proc hexToDecBigString*(self: Controller, hex: string): string {.slot.} = try: diff --git a/src/app_service/service/wallet_connect/async_tasks.nim b/src/app_service/service/wallet_connect/async_tasks.nim new file mode 100644 index 0000000000..2520e73103 --- /dev/null +++ b/src/app_service/service/wallet_connect/async_tasks.nim @@ -0,0 +1,91 @@ +import backend/backend +import backend/eth + +type + AsyncGetEstimatedTimeArgs = ref object of QObjectTaskArg + topic: string + chainId: int + maxFeePerGasHex: string + + AsyncSuggestedFeesArgs = ref object of QObjectTaskArg + topic: string + chainId: int + + AsyncEstimateGasArgs = ref object of QObjectTaskArg + topic: string + chainId: int + txJson: string + +proc asyncGetEstimatedTimeTask(argsEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[AsyncGetEstimatedTimeArgs](argsEncoded) + let result = %*{ + "topic": arg.topic, + "chainId": arg.chainId, + "estimatedTime": EstimatedTime.Unknown, + } + try: + var maxFeePerGas: float64 + if arg.maxFeePerGasHex.isEmptyOrWhitespace: + let chainFeesResult = eth.suggestedFees(arg.chainId).result + let chainFees = chainFeesResult.toSuggestedFeesDto() + if chainFees.isNil: + arg.finish(result) + + # For non-EIP-1559 chains, we use the high fee + if chainFees.eip1559Enabled: + maxFeePerGas = chainFees.maxFeePerGasM + else: + maxFeePerGas = chainFees.maxFeePerGasL + else: + try: + let maxFeePerGasInt = parseHexInt(arg.maxFeePerGasHex) + maxFeePerGas = maxFeePerGasInt.float + except ValueError: + error "failed to parse maxFeePerGasHex", msg = arg.maxFeePerGasHex + arg.finish(result) + + let estimatedTime = backend.getTransactionEstimatedTime(arg.chainId, $(maxFeePerGas)).result.getInt + result["estimatedTime"] = %estimatedTime + arg.finish(result) + except Exception as e: + error "asyncGetEstimatedTime failed: ", msg=e.msg + arg.finish(result) + +proc asyncSuggestedFeesTask(argsEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[AsyncSuggestedFeesArgs](argsEncoded) + let result = %*{ + "topic": arg.topic, + "chainId": arg.chainId, + "suggestedFees": %*{}, + } + try: + let response = eth.suggestedFees(arg.chainId) + result["suggestedFees"] = response.result + arg.finish(result) + except Exception as e: + error "asyncSuggestedFees failed: ", msg=e.msg + arg.finish(result) + +proc asyncEstimateGasTask(argsEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[AsyncEstimateGasArgs](argsEncoded) + let result = %*{ + "topic": arg.topic, + "chainId": arg.chainId, + "estimatedGas": "", + } + try: + let tx = parseJson(arg.txJson) + let transaction = %*{ + "from": tx["from"].getStr, + "to": tx["to"].getStr, + "data": tx["data"].getStr + } + if tx.hasKey("value"): + transaction["value"] = tx["value"] + + let response = eth.estimateGas(arg.chainId, %* [transaction]) + result["estimatedGas"] = response.result + arg.finish(result) + except Exception as e: + error "asyncGasLimit failed: ", msg=e.msg + arg.finish(result) \ No newline at end of file diff --git a/src/app_service/service/wallet_connect/service.nim b/src/app_service/service/wallet_connect/service.nim index 98926fda87..17c0a242ce 100644 --- a/src/app_service/service/wallet_connect/service.nim +++ b/src/app_service/service/wallet_connect/service.nim @@ -14,21 +14,45 @@ import app/global/global_singleton import app/core/eventemitter import app/core/signals/types -import app/core/tasks/[threadpool] +import app/core/[main] +import app/core/tasks/[qt, threadpool] import app/modules/shared_modules/keycard_popup/io_interface as keycard_shared_module +include app_service/common/json_utils +include app/core/tasks/common +include async_tasks + logScope: topics = "wallet-connect-service" # include async_tasks const UNIQUE_WALLET_CONNECT_MODULE_IDENTIFIER* = "WalletSection-WCModule" +const SIGNAL_ESTIMATED_TIME_RESPONSE* = "estimatedTimeResponse" +const SIGNAL_SUGGESTED_FEES_RESPONSE* = "suggestedFeesResponse" +const SIGNAL_ESTIMATED_GAS_RESPONSE* = "estimatedGasResponse" type AuthenticationResponseFn* = proc(keyUid: string, password: string, pin: string) SignResponseFn* = proc(keyUid: string, signature: string) +type + EstimatedTimeArgs* = ref object of Args + topic*: string + chainId*: int + estimatedTime*: int + + SuggestedFeesArgs* = ref object of Args + topic*: string + chainId*: int + suggestedFees*: JsonNode + + EstimatedGasArgs* = ref object of Args + topic*: string + chainId*: int + estimatedGas*: string + QtObject: type Service* = ref object of QObject events: EventEmitter @@ -206,64 +230,107 @@ QtObject: return response.getStr # empty maxFeePerGasHex will fetch the current chain's maxFeePerGas - proc getEstimatedTime*(self: Service, chainId: int, maxFeePerGasHex: string): EstimatedTime = - var maxFeePerGas: float64 - if maxFeePerGasHex.isEmptyOrWhitespace: - let chainFees = self.transactions.suggestedFees(chainId) - if chainFees.isNil: - return EstimatedTime.Unknown + proc getEstimatedTime*(self: Service, topic: string, chainId: int, maxFeePerGasHex: string) = + let request = AsyncGetEstimatedTimeArgs( + tptr: asyncGetEstimatedTimeTask, + vptr: cast[ByteAddress](self.vptr), + slot: "estimatedTimeResponse", + topic: topic, + chainId: chainId, + maxFeePerGasHex: maxFeePerGasHex + ) + self.threadpool.start(request) - # For non-EIP-1559 chains, we use the high fee - if chainFees.eip1559Enabled: - maxFeePerGas = chainFees.maxFeePerGasM - else: - maxFeePerGas = chainFees.maxFeePerGasL - else: - try: - let maxFeePerGasInt = parseHexInt(maxFeePerGasHex) - maxFeePerGas = maxFeePerGasInt.float - except ValueError: - error "failed to parse maxFeePerGasHex", maxFeePerGasHex - return EstimatedTime.Unknown + proc estimatedTimeResponse*(self: Service, response: string) {.slot.} = + try: + let responseObj = response.parseJson + let args = EstimatedTimeArgs( + topic: responseObj["topic"].getStr, + chainId: responseObj["chainId"].getInt, + estimatedTime: responseObj["estimatedTime"].getInt + ) + self.events.emit(SIGNAL_ESTIMATED_TIME_RESPONSE, args) + except Exception as e: + error "failed to parse estimated time response", msg = e.msg - return self.transactions.getEstimatedTime(chainId, $(maxFeePerGas)) + proc requestSuggestedFees*(self: Service, topic: string, chainId: int) = + let request = AsyncSuggestedFeesArgs( + tptr: asyncSuggestedFeesTask, + vptr: cast[ByteAddress](self.vptr), + slot: "suggestedFeesResponse", + topic: topic, + chainId: chainId + ) + self.threadpool.start(request) -proc getSuggestedFees*(self: Service, chainId: int): SuggestedFeesDto = - return self.transactions.suggestedFees(chainId) + proc suggestedFeesResponse*(self: Service, response: string) {.slot.} = + try: + let responseObj = response.parseJson + let args = SuggestedFeesArgs( + topic: responseObj["topic"].getStr, + chainId: responseObj["chainId"].getInt, + suggestedFees: responseObj["suggestedFees"] + ) + self.events.emit(SIGNAL_SUGGESTED_FEES_RESPONSE, args) + except Exception as e: + error "failed to parse suggested fees response", msg = e.msg -proc disconnectKeycardReponseSignal(self: Service) = - self.events.disconnect(self.connectionKeycardResponse) + proc disconnectKeycardReponseSignal(self: Service) = + self.events.disconnect(self.connectionKeycardResponse) -proc connectKeycardReponseSignal(self: Service) = - self.connectionKeycardResponse = self.events.onWithUUID(SIGNAL_KEYCARD_RESPONSE) do(e: Args): - let args = KeycardLibArgs(e) - self.disconnectKeycardReponseSignal() - if self.signCallback == nil: - error "unexpected user authenticated event; no callback set" - return - defer: - self.signCallback = nil - let currentFlow = self.keycardService.getCurrentFlow() - if currentFlow != KCSFlowType.Sign: - error "unexpected keycard flow type: ", currentFlow - self.signCallback("", "") - return - let signature = "0x" & - singletonInstance.utils.removeHexPrefix(args.flowEvent.txSignature.r) & - singletonInstance.utils.removeHexPrefix(args.flowEvent.txSignature.s) & - singletonInstance.utils.removeHexPrefix(args.flowEvent.txSignature.v) - self.signCallback(args.flowEvent.keyUid, signature) + proc connectKeycardReponseSignal(self: Service) = + self.connectionKeycardResponse = self.events.onWithUUID(SIGNAL_KEYCARD_RESPONSE) do(e: Args): + let args = KeycardLibArgs(e) + self.disconnectKeycardReponseSignal() + if self.signCallback == nil: + error "unexpected user authenticated event; no callback set" + return + defer: + self.signCallback = nil + let currentFlow = self.keycardService.getCurrentFlow() + if currentFlow != KCSFlowType.Sign: + error "unexpected keycard flow type: ", currentFlow + self.signCallback("", "") + return + let signature = "0x" & + singletonInstance.utils.removeHexPrefix(args.flowEvent.txSignature.r) & + singletonInstance.utils.removeHexPrefix(args.flowEvent.txSignature.s) & + singletonInstance.utils.removeHexPrefix(args.flowEvent.txSignature.v) + self.signCallback(args.flowEvent.keyUid, signature) -proc cancelCurrentFlow*(self: Service) = - self.keycardService.cancelCurrentFlow() + proc cancelCurrentFlow*(self: Service) = + self.keycardService.cancelCurrentFlow() -proc runSigningOnKeycard*(self: Service, keyUid: string, path: string, hashedMessageToSign: string, pin: string, callback: SignResponseFn): bool = - if pin.len == 0: - return false - if self.signCallback != nil: - return false - self.signCallback = callback - self.cancelCurrentFlow() - self.connectKeycardReponseSignal() - self.keycardService.startSignFlow(path, hashedMessageToSign, pin) - return true \ No newline at end of file + proc runSigningOnKeycard*(self: Service, keyUid: string, path: string, hashedMessageToSign: string, pin: string, callback: SignResponseFn): bool = + if pin.len == 0: + return false + if self.signCallback != nil: + return false + self.signCallback = callback + self.cancelCurrentFlow() + self.connectKeycardReponseSignal() + self.keycardService.startSignFlow(path, hashedMessageToSign, pin) + return true + + proc requestGasEstimate*(self: Service, topic: string, tx: JsonNode, chainId: int) = + let request = AsyncEstimateGasArgs( + tptr: asyncEstimateGasTask, + vptr: cast[ByteAddress](self.vptr), + slot: "estimatedGasResponse", + topic: topic, + chainId: chainId, + txJson: $tx + ) + self.threadpool.start(request) + + proc estimatedGasResponse*(self: Service, response: string) {.slot.} = + try: + let responseObj = response.parseJson + let args = EstimatedGasArgs( + topic: responseObj["topic"].getStr, + chainId: responseObj["chainId"].getInt, + estimatedGas: responseObj["estimatedGas"].getStr + ) + self.events.emit(SIGNAL_ESTIMATED_GAS_RESPONSE, args) + except Exception as e: + error "failed to parse estimated gas response", msg = e.msg \ No newline at end of file diff --git a/storybook/pages/DAppSignRequestModalPage.qml b/storybook/pages/DAppSignRequestModalPage.qml index 3b5532d111..9b655808c3 100644 --- a/storybook/pages/DAppSignRequestModalPage.qml +++ b/storybook/pages/DAppSignRequestModalPage.qml @@ -44,9 +44,10 @@ SplitView { cryptoFees: "0.001" estimatedTime: "3-5 minutes" feesLoading: feesLoading.checked + estimatedTimeLoading: feesLoading.checked hasFees: hasFees.checked enoughFundsForTransaction: enoughFeesForTransaction.checked - enoughFundsForFees: enoughFeesForGas.checked + enoughFundsForFees: enoughFeesForGas.checked || !feesLoading.checked // sun emoji accountEmoji: "\u2600" @@ -133,7 +134,7 @@ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Fusce nibh. Etiam quis CheckBox { id: feesLoading text: "Fees loading" - checked: false + checked: true } CheckBox { id: hasFees diff --git a/storybook/pages/DAppsWorkflowPage.qml b/storybook/pages/DAppsWorkflowPage.qml index dee3c954d6..d0b9af6592 100644 --- a/storybook/pages/DAppsWorkflowPage.qml +++ b/storybook/pages/DAppsWorkflowPage.qml @@ -78,8 +78,11 @@ Item { walletConnectEnabled: wcService.walletConnectFeatureEnabled connectorEnabled: wcService.connectorFeatureEnabled - //formatBigNumber: (number, symbol, noSymbolOption) => wcService.walletRootStore.currencyStore.formatBigNumber(number, symbol, noSymbolOption) - + formatBigNumber: (number, symbol, noSymbolOption) => { + print ("formatBigNumber", number, symbol, noSymbolOption) + return parseFloat(number).toLocaleString(Qt.locale(), 'f', 2) + + (noSymbolOption ? "" : " " + (symbol || Qt.locale().currencySymbol(Locale.CurrencyIsoCode))) + } onDisconnectRequested: (connectionId) => wcService.disconnectDapp(connectionId) onPairingRequested: (uri) => wcService.pair(uri) onPairingValidationRequested: (uri) => wcService.validatePairingUri(uri) @@ -87,6 +90,7 @@ Item { onConnectionDeclined: (pairingId) => wcService.rejectPairSession(pairingId) onSignRequestAccepted: (connectionId, requestId) => wcService.sign(connectionId, requestId) onSignRequestRejected: (connectionId, requestId) => wcService.rejectSign(connectionId, requestId, false /*hasError*/) + onSignRequestIsLive: (connectionId, requestId) => wcService.signRequestIsLive(connectionId, requestId) Connections { target: dappsWorkflow.wcService @@ -170,7 +174,7 @@ Item { model: dappsService.sessionRequestsModel delegate: RowLayout { StatusBaseText { - text: SQUtils.Utils.elideAndFormatWalletAddress(model.topic, 6, 4) + text: SQUtils.Utils.elideAndFormatWalletAddress(model.requestItem.topic, 6, 4) Layout.fillWidth: true } } @@ -393,6 +397,10 @@ Item { signal userAuthenticationFailed(string topic, string id) signal signingResult(string topic, string id, string data) signal activeSessionsReceived(var activeSessionsJsonObj, bool success) + // Fees and gas + signal estimatedTimeResponse(string topic, int timeCategory, bool success) + signal suggestedFeesResponse(string topic, var suggestedFeesJsonObj, bool success) + signal estimatedGasResponse(string topic, string gasEstimate, bool success) function addWalletConnectSession(sessionJson) { console.info("Add Persisted Session", sessionJson) @@ -479,8 +487,17 @@ Item { signingResult(topic, id, "0xf8672a8402fb7acf82520894e2d622c817878da5143bbe068") } - function getEstimatedTime(chainId, maxFeePerGas) { - return Constants.TransactionEstimatedTime.LessThanThreeMins + function requestEstimatedTime(topic, chainId, maxFeePerGasHex) { + estimatedTimeResponse(topic, Constants.TransactionEstimatedTime.LessThanThreeMins, true) + } + + function requestSuggestedFees(topic, chainId) { + const suggestedFees = getSuggestedFees() + suggestedFeesResponse(topic, suggestedFees, true) + } + + function requestGasEstimate(topic, chainId, txObj) { + estimatedGasResponse(topic, "0x5208", true) } function getSuggestedFees() { @@ -488,9 +505,9 @@ Item { gasPrice: 2.0, baseFee: 5.0, maxPriorityFeePerGas: 2.0, - maxFeePerGasL: 1.0, - maxFeePerGasM: 1.1, - maxFeePerGasH: 1.2, + maxFeePerGasLow: 1.0, + maxFeePerGasMedium: 1.1, + maxFeePerGasHigh: 1.2, l1GasFee: 4.0, eip1559Enabled: true } @@ -569,7 +586,8 @@ Item { sessions.forEach(function(session) { sessionsModel.append(session) - let firstIconUrl = session.peer.metadata.icons.length > 0 ? session.peer.metadata.icons[0] : "" + let firstIconUrl = !!session.peer.metadata.icons && session.peer.metadata.icons.length > 0 ? + session.peer.metadata.icons[0] : "" let persistedDapp = { "name": session.peer.metadata.name, "url": session.peer.metadata.url, diff --git a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml index d42c72869b..d4c5900796 100644 --- a/storybook/qmlTests/tests/tst_DAppsWorkflow.qml +++ b/storybook/qmlTests/tests/tst_DAppsWorkflow.qml @@ -150,6 +150,11 @@ Item { signal userAuthenticationFailed(string topic, string id) signal signingResult(string topic, string id, string data) + signal estimatedTimeResponse(string topic, int timeCategory, bool success) + signal suggestedFeesResponse(string topic, var suggestedFeesJsonObj, bool success) + signal estimatedGasResponse(string topic, string gasEstimate, bool success) + + // By default, return no dapps in store function getDapps() { dappsListReceived(dappsListReceivedJsonStr) @@ -181,23 +186,27 @@ Item { updateWalletConnectSessionsCalls.push({activeTopicsJson}) } - function getEstimatedTime(chainId, maxFeePerGas) { - return Constants.TransactionEstimatedTime.LessThanThreeMins + function requestEstimatedTime(topic, chainId, maxFeePerGas) { + estimatedTimeResponse(topic, Constants.TransactionEstimatedTime.LessThanThreeMins, true) } property var mockedSuggestedFees: ({ gasPrice: 2.0, baseFee: 5.0, maxPriorityFeePerGas: 2.0, - maxFeePerGasL: 1.0, - maxFeePerGasM: 1.1, - maxFeePerGasH: 1.2, + maxFeePerGasLow: 1.0, + maxFeePerGasMedium: 1.1, + maxFeePerGasHigh: 1.2, l1GasFee: 0.0, eip1559Enabled: true }) - function getSuggestedFees() { - return mockedSuggestedFees + function requestSuggestedFees(topic, chainId) { + suggestedFeesResponse(topic, mockedSuggestedFees, true) + } + + function requestGasEstimate(topic, chainId, tx) { + estimatedGasResponse(topic, "0x5208", true) } function hexToDec(hex) { @@ -447,7 +456,7 @@ Item { // Override the suggestedFees if (!!data.maxFeePerGasM) { - handler.store.mockedSuggestedFees.maxFeePerGasM = data.maxFeePerGasM + handler.store.mockedSuggestedFees.maxFeePerGasMedium = data.maxFeePerGasM } if (!!data.l1GasFee) { handler.store.mockedSuggestedFees.l1GasFee = data.l1GasFee @@ -473,10 +482,11 @@ Item { callback({"b536a": JSON.parse(Testing.formatApproveSessionResponse([chainId, 7], [testAddress]))}) let request = handler.requestsModel.findById(session.id) + request.setActive() verify(!!request, "expected request to be found") - compare(request.fiatMaxFees.toFixed(), data.expect.fee, "expected ethMaxFees to be set") + compare(request.fiatMaxFees.toFixed(), data.expect.fee, "expected fiatMaxFees to be set") // storybook's CurrenciesStore mock up getFiatValue returns the balance - compare(request.ethMaxFees, data.expect.fee, "expected fiatMaxFees to be set") + compare(request.ethMaxFees, data.expect.fee, "expected ethMaxFees to be set") verify(request.haveEnoughFunds, "expected haveEnoughFunds to be set") compare(request.haveEnoughFees, data.expect.haveEnoughForFees, "expected haveEnoughForFees to be set") verify(!!request.feesInfo, "expected feesInfo to be set") diff --git a/storybook/qmlTests/tests/tst_SignRequestPlugin.qml b/storybook/qmlTests/tests/tst_SignRequestPlugin.qml index 6afa2e4752..3a3f921b10 100644 --- a/storybook/qmlTests/tests/tst_SignRequestPlugin.qml +++ b/storybook/qmlTests/tests/tst_SignRequestPlugin.qml @@ -47,6 +47,10 @@ Item { signal userAuthenticationFailed(string topic, string id) signal signingResult(string topic, string id, string data) + signal estimatedTimeResponse(string topic, int timeCategory, bool success) + signal suggestedFeesResponse(string topic, var suggestedFeesJsonObj, bool success) + signal estimatedGasResponse(string topic, string gasEstimate, bool success) + function hexToDec(hex) { return parseInt(hex, 16) } @@ -122,7 +126,6 @@ Item { address: "0x123" } } - currentCurrency: "USD" requests: SessionRequestsModel {} getFiatValue: (balance, cryptoSymbol) => { return parseFloat(balance) diff --git a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml index 5d9a329efa..86a514de0a 100644 --- a/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml +++ b/ui/app/AppLayouts/Wallet/panels/DAppsWorkflow.qml @@ -323,6 +323,7 @@ DappsComboBox { cryptoFees: request.ethMaxFees ? request.ethMaxFees.toFixed() : "" estimatedTime: WalletUtils.getLabelForEstimatedTxTime(request.estimatedTimeCategory) feesLoading: hasFees && (!fiatFees || !cryptoFees) + estimatedTimeLoading: request.estimatedTimeCategory === Constants.TransactionEstimatedTime.Unknown hasFees: signingTransaction enoughFundsForTransaction: request.haveEnoughFunds enoughFundsForFees: request.haveEnoughFees diff --git a/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml b/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml index 213a8b893e..e2ed5901c1 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/DAppsModule.qml @@ -10,6 +10,8 @@ import StatusQ.Core.Utils 0.1 as SQUtils import shared.stores 1.0 import utils 1.0 +import "./internal" + /// Component that provides the dapps integration for the wallet. /// It provides the following features: /// - WalletConnect integration @@ -263,6 +265,12 @@ SQUtils.QObject { } } + // The fees broker to handle all fees requests for all components and connections + TransactionFeesBroker { + id: feesBroker + store: root.store + } + // 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 { @@ -272,10 +280,10 @@ SQUtils.QObject { groupedAccountAssetsModel: root.groupedAccountAssetsModel networksModel: root.networksModel accountsModel: root.accountsModel - currentCurrency: root.currenciesStore.currentCurrency store: root.store requests: root.requestsModel dappsModel: root.dappsModel + feesBroker: feesBroker getFiatValue: (value, currency) => { return root.currenciesStore.getFiatValue(value, currency) @@ -293,10 +301,10 @@ SQUtils.QObject { groupedAccountAssetsModel: root.groupedAccountAssetsModel networksModel: root.networksModel accountsModel: root.accountsModel - currentCurrency: root.currenciesStore.currentCurrency store: root.store requests: root.requestsModel dappsModel: root.dappsModel + feesBroker: feesBroker getFiatValue: (value, currency) => { return root.currenciesStore.getFiatValue(value, currency) diff --git a/ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesBroker.qml b/ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesBroker.qml new file mode 100644 index 0000000000..459d278d7d --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesBroker.qml @@ -0,0 +1,155 @@ +import QtQuick 2.15 + +import AppLayouts.Wallet.services.dapps.types 1.0 +import StatusQ.Core.Utils 0.1 as SQUtils + +import shared.stores 1.0 + +// The TransactionFeesBroker is responsible for managing the subscriptions to the estimated time, fees and gas limit for a transaction +// It will bundle the same requests to optimise the backend load and will notify the subscribers when the data is ready +// It can only work with a TransactionFeesSubscriber +SQUtils.QObject { + id: root + + required property DAppsStore store + property int interval: 5000 + + function subscribe(subscriberObj) { + if (!(subscriberObj instanceof TransactionFeesSubscriber)) { + console.error("Invalid subscriber object") + return + } + + try { + const estimatedTimeSub = estimatedTimeSubscription.createObject(subscriberObj, { + subscriber: subscriberObj + }) + const feesSub = feesSubscription.createObject(subscriberObj, { + subscriber: subscriberObj + }) + const gasLimitSub = gasLimitSubscription.createObject(subscriberObj, { + subscriber: subscriberObj + }) + if (subscriberObj.txObject && (subscriberObj.txObject.gas || subscriberObj.txObject.gasLimit)) { + subscriberObj.setGas(subscriberObj.txObject.gas || subscriberObj.txObject.gasLimit) + } + broker.subscribe(estimatedTimeSub) + broker.subscribe(feesSub) + broker.subscribe(gasLimitSub) + } catch (e) { + console.error("Error subscribing to estimated time: ", e) + } + } + + enum SubscriptionType { + Fees, + GasLimit, + EstimatedTime + } + + SQUtils.SubscriptionBroker { + id: broker + + active: Qt.application.state === Qt.ApplicationActive + onRequest: d.computeFees(topic) + } + + SQUtils.QObject { + id: d + + function computeFees(topic) { + try { + const args = JSON.parse(topic) + switch (args.type) { + case TransactionFeesBroker.SubscriptionType.Fees: + root.store.requestSuggestedFees(topic, args.chainId) + break + case TransactionFeesBroker.SubscriptionType.GasLimit: + root.store.requestGasEstimate(topic, args.chainId, args.tx) + break + case TransactionFeesBroker.SubscriptionType.EstimatedTime: + root.store.requestEstimatedTime(topic, args.chainId, args.maxFeePerGasHex) + break + } + } catch (e) { + console.error("Error computing fees: ", e) + } + } + } + + Component { + id: feesSubscription + SQUtils.Subscription { + required property TransactionFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.SubscriptionType.Fees, + chainId: subscriber.chainId + }) + isReady: subscriber.active + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: { + if (!response || !response.success) + return + subscriber.setFees(response.suggestedFees) + } + } + } + + Component { + id: estimatedTimeSubscription + SQUtils.Subscription { + required property TransactionFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.SubscriptionType.EstimatedTime, + chainId: subscriber.chainId, + maxFeePerGasHex: subscriber.txObject ? (subscriber.txObject.maxFeePerGas || subscriber.txObject.gasPrice || "") : + "" + }) + isReady: subscriber.active + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: { + if (!response || !response.success) + return + subscriber.setEstimatedTime(response.estimatedTime) + } + notificationInterval: root.interval + } + } + + Component { + id: gasLimitSubscription + SQUtils.Subscription { + required property TransactionFeesSubscriber subscriber + readonly property var requestArgs: ({ + type: TransactionFeesBroker.SubscriptionType.GasLimit, + chainId: subscriber.chainId, + tx: subscriber.txObject + }) + isReady: subscriber.active && !!subscriber.txObject && !!subscriber.chainId && !subscriber.gasLimit /*Ask for gas just once*/ + topic: isReady ? JSON.stringify(requestArgs) : "" + onResponseChanged: { + if (!response || !response.success) + return + subscriber.setGas(response.gasEstimate) + } + notificationInterval: root.interval + } + } + + Connections { + id: storeConnections + target: root.store + + function onEstimatedTimeResponse(topic, timeCategory, success) { + broker.response(topic, { estimatedTime: timeCategory, success}) + } + + function onSuggestedFeesResponse(topic, suggestedFeesJson, success) { + broker.response(topic, { suggestedFees: suggestedFeesJson, success: success }) + } + + function onEstimatedGasResponse(topic, gasEstimate, success) { + broker.response(topic, { gasEstimate, success }) + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesSubscriber.qml b/ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesSubscriber.qml new file mode 100644 index 0000000000..f0e6357be9 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/internal/TransactionFeesSubscriber.qml @@ -0,0 +1,134 @@ +import QtQuick 2.15 + +import StatusQ.Core.Utils 0.1 as SQUtils + +import utils 1.0 + +SQUtils.QObject { + id: root + + /* + Input properties + */ + // standard transaction object + required property var txObject + // subscriber id + required property string key + // chainId -> chain id for the transaction + required property int chainId + // active specifies if the subscriber has an active subscription + required property bool active + // selectedFeesMode -> selected fees mode. Defaults to Constants.TransactionFeesMode.Medium + property int selectedFeesMode: Constants.TransactionFeesMode.Medium + + // Required function to be implemented by the subscriber + required property var hexToDec /*(hexValue) => decValue*/ + + /* + Published properties + */ + // estimatedTimeResponse -> maps to Constants.TransactionEstimatedTime + readonly property int estimatedTimeResponse: d.estimatedTimeResponse + // maxEthFee -> Big number in Gwei. Represens the total fees for the transaction + readonly property var maxEthFee: d.computedFees + // feesInfo -> status-go fees info with updated maxFeePerGas based on selectedFeesMode + readonly property var feesInfo: d.computedFeesInfo + // gasLimit -> gas limit for the transaction + readonly property var gasLimit: d.gasResponse + + function setFees(fees) { + if (d.feesResponse === fees) { + return + } + d.feesResponse = fees + d.resetFees() + } + + function setGas(gas) { + if (d.gasResponse === gas) { + return + } + + d.gasResponse = gas + d.resetFees() + } + + function setEstimatedTime(estimatedTime) { + if(!estimatedTime) { + estimatedTime = Constants.TransactionEstimatedTime.Unknown + return + } + d.estimatedTimeResponse = estimatedTime + } + + QtObject { + id: d + + property int estimatedTimeResponse: Constants.TransactionEstimatedTime.Unknown + property var feesResponse + property var gasResponse + // Eth max fee in Gwei + property var computedFees + // feesResponse with additional `maxFeePerGas` property based on selectedFeesMode + property var computedFeesInfo + + function resetFees() { + if (!d.feesResponse) { + return + } + if (!gasResponse) { + return + } + try { + d.computedFees = getEstimatedMaxFees() + d.computedFeesInfo = d.feesResponse + d.computedFeesInfo.maxFeePerGas = getFeesForFeesMode(d.feesResponse) + } catch (e) { + console.error("Failed to compute fees", e, e.stack) + } + } + + function getFeesForFeesMode(feesObj) { + if (!(feesObj.hasOwnProperty("maxFeePerGasLow") && + feesObj.hasOwnProperty("maxFeePerGasMedium") && + feesObj.hasOwnProperty("maxFeePerGasHigh"))) { + print ("feesObj", JSON.stringify(feesObj)) + throw new Error("inappropriate fees object provided") + } + const BigOps = SQUtils.AmountsArithmetic + if (!feesObj.eip1559Enabled && !!feesObj.gasPrice) { + return feesObj.gasPrice + } + + switch (root.selectedFeesMode) { + case Constants.FeesMode.Low: + return feesObj.maxFeePerGasLow + case Constants.FeesMode.Medium: + return feesObj.maxFeePerGasMedium + case Constants.FeesMode.High: + return feesObj.maxFeePerGasHigh + default: + throw new Error("unknown selected mode") + } + } + + function getEstimatedMaxFees() { + // Note: Use the received fee arguments only once! + // Complete what's missing with the suggested fees + const BigOps = SQUtils.AmountsArithmetic + const gasLimitStr = root.hexToDec(d.gasResponse) + + const gasLimit = BigOps.fromString(gasLimitStr) + const maxFeesPerGas = BigOps.fromNumber(getFeesForFeesMode(d.feesResponse)) + const l1GasFee = d.feesResponse.l1GasFee ? BigOps.fromNumber(d.feesResponse.l1GasFee) + : BigOps.fromNumber(0) + + let maxGasFee = BigOps.times(gasLimit, maxFeesPerGas).plus(l1GasFee) + return maxGasFee + } + + Component.onCompleted: { + resetFees() + } + } +} \ 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 index 1b934185a5..ecfcea49f9 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/plugins/SignRequestPlugin.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/plugins/SignRequestPlugin.qml @@ -10,6 +10,8 @@ import StatusQ.Core.Utils 0.1 as SQUtils import shared.stores 1.0 import utils 1.0 +import "../internal" + /// Plugin that listens for session requests and manages the lifecycle of the request. SQUtils.QObject { id: root @@ -34,11 +36,14 @@ SQUtils.QObject { /// 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 + // The fees broker that provides the updated fees + property TransactionFeesBroker feesBroker: TransactionFeesBroker { + id: feesBroker + store: root.store + } // Function to transform the eth value to fiat property var getFiatValue: (maxFeesEthStr, token /*Constants.ethToken*/) => console.error("getFiatValue not implemented") @@ -56,7 +61,13 @@ SQUtils.QObject { } function requestResolved(topic, id) { + const request = root.requests.findRequest(topic, id) + if (!request) { + console.error("Error finding request for topic", topic, "id", id) + return + } root.requests.removeRequest(topic, id) + request.destroy() } function requestExpired(sessionId) { @@ -77,7 +88,12 @@ SQUtils.QObject { SessionRequestWithAuth { id: request store: root.store - + estimatedTimeCategory: feesSubscriber.estimatedTimeResponse + feesInfo: feesSubscriber.feesInfo + haveEnoughFunds: d.hasEnoughEth(request.chainId, request.accountAddress, request.value) + haveEnoughFees: haveEnoughFunds && d.hasEnoughEth(request.chainId, request.accountAddress, request.ethMaxFees) + ethMaxFees: feesSubscriber.maxEthFee ? SQUtils.AmountsArithmetic.div(feesSubscriber.maxEthFee, SQUtils.AmountsArithmetic.fromNumber(1, 9)) : null + fiatMaxFees: ethMaxFees ? SQUtils.AmountsArithmetic.fromString(root.getFiatValue(ethMaxFees.toString(), Constants.ethToken)) : null function signedHandler(topic, id, data) { if (topic != request.topic || id != request.requestId) { return @@ -93,26 +109,29 @@ SQUtils.QObject { } onActiveChanged: { - if (active === false) { - d.unsubscribeForFeeUpdates(request.topic, request.requestId) - } - if (active === true) { - d.subscribeForFeeUpdates(request.topic, request.requestId) + if (active) { + feesBroker.subscribe(feesSubscriber) } } + onAccepted: { + active = false + } + + onExpired: { + active = false + } + onRejected: (hasError) => { + active = false 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 { @@ -125,6 +144,17 @@ SQUtils.QObject { root.rejected(request.topic, request.requestId, true /*hasError*/) root.store.signingResult.disconnect(request.signedHandler) } + active = false + } + + TransactionFeesSubscriber { + id: feesSubscriber + key: request.requestId + chainId: request.chainId + txObject: SessionRequest.getTxObject(request.method, request.data) + active: request.active && !!txObject + selectedFeesMode: Constants.FeesMode.Medium + hexToDec: root.store.hexToDec } } } @@ -192,7 +222,7 @@ SQUtils.QObject { } root.requests.enqueue(res.obj) } catch (e) { - console.error("Error processing session request event", e) + console.error("Error processing session request event", e, e.stack) root.rejected(event.topic, event.id, true) } } @@ -211,7 +241,6 @@ SQUtils.QObject { } request.setExpired() - d.unsubscribeForFeeUpdates(request.topic, request.requestId) } // returns { // obj: obj or nil @@ -225,13 +254,6 @@ SQUtils.QObject { 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, @@ -254,76 +276,50 @@ SQUtils.QObject { 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 + function hasEnoughEth(chainId, accountAddress, requiredEth) { + if (!requiredEth) { + return true } - if (!SessionRequest.isTransactionMethod(requestItem.method)) { - return + if (!accountAddress || !chainId) { + console.error("No account or chain provided to check funds", accountAddress, chainId) + return true } - const mainNet = lookupMainnetNetwork() - if (!mainNet) { - console.error("Mainnet network not found") - return { obj: null, code: SessionRequest.RuntimeError } + const token = SQUtils.ModelUtils.getByKey(root.groupedAccountAssetsModel, "tokensKey", Constants.ethToken) + const balance = getBalance(chainId, accountAddress, token) + + if (!balance) { + console.error("Error fetching balance for account", accountAddress, "on chain", chainId) + return true } - 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 + const BigOps = SQUtils.AmountsArithmetic + const haveEnoughFunds = BigOps.cmp(balance, requiredEth) >= 0 + return haveEnoughFunds } - // 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 + function getBalance(chainId, address, token) { + if (!token || !token.balances) { + console.error("Error token balances lookup", token) + return null + } + const BigOps = SQUtils.AmountsArithmetic + const accEth = SQUtils.ModelUtils.getFirstModelEntryIf(token.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 } - 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) + const accountFundsWei = BigOps.fromString(accEth.balance) + return BigOps.div(accountFundsWei, BigOps.fromNumber(1, 18)) } function executeSessionRequest(request, password, pin, payload) { @@ -364,24 +360,7 @@ SQUtils.QObject { 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 - + const txObj = prepareTxForStatusGo(SessionRequest.getTxObject(request.method, request.data), payload) if (request.method === SessionRequest.methods.signTransaction.name) { root.store.signTransaction(request.topic, request.requestId, @@ -405,267 +384,26 @@ SQUtils.QObject { 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} + function prepareTxForStatusGo(txObj, feesInfo) { + if (!!feesInfo) { + let hexFeesJson = root.store.convertFeesInfoToHex(JSON.stringify(feesInfo)) + if (!!hexFeesJson) { + let feesInfo = JSON.parse(hexFeesJson) + if (feesInfo.maxFeePerGas) { + txObj.maxFeePerGas = feesInfo.maxFeePerGas } - 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) + if (feesInfo.maxPriorityFeePerGas) { + txObj.maxPriorityFeePerGas = feesInfo.maxPriorityFeePerGas } } + delete txObj.gasLimit + delete txObj.gasPrice + delete txObj.gas + delete txObj.type } + // Remove nonce from txObj to be auto-filled by the wallet + delete txObj.nonce + return txObj } } } \ 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 c1e492ff3f..d6f710d674 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequest.qml @@ -296,12 +296,12 @@ QtObject { } case methods.signTypedData_v4.name: { const stringPayload = methods.signTypedData_v4.getMessageFromData(data) - payload = JSON.stringify(JSON.parse(stringPayload), null, 2) + payload = JSON.parse(stringPayload) break } case methods.signTypedData.name: { const stringPayload = methods.signTypedData.getMessageFromData(data) - payload = JSON.stringify(JSON.parse(stringPayload), null, 2) + payload = JSON.parse(stringPayload) break } case methods.signTransaction.name: @@ -332,23 +332,35 @@ QtObject { 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) + parsedTransaction.value = hexToEth(parsedTransaction.value, hexToDec) } if (parsedTransaction.hasOwnProperty("gasLimit")) { parsedTransaction.gasLimit = parseInt(hexToDec(parsedTransaction.gasLimit)) } + if (parsedTransaction.hasOwnProperty("gas")) { + parsedTransaction.gas = parseInt(hexToDec(parsedTransaction.gas)) + } if (parsedTransaction.hasOwnProperty("nonce")) { parsedTransaction.nonce = parseInt(hexToDec(parsedTransaction.nonce)) } + if (parsedTransaction.hasOwnProperty("from")) { + parsedTransaction.from = parsedTransaction.from + } + if (parsedTransaction.hasOwnProperty("to")) { + parsedTransaction.to = parsedTransaction.to + } + if (parsedTransaction.hasOwnProperty("data")) { + parsedTransaction.data = parsedTransaction.data + } + if (parsedTransaction.hasOwnProperty("maxFeePerGas")) { + delete parsedTransaction.maxFeePerGas + } + if (parsedTransaction.hasOwnProperty("maxPriorityFeePerGas")) { + delete parsedTransaction.maxPriorityFeePerGas + } + if (parsedTransaction.hasOwnProperty("gasPrice")) { + delete parsedTransaction.gasPrice + } return parsedTransaction } diff --git a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml index 8237cacb00..7bb25a10ca 100644 --- a/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml +++ b/ui/app/AppLayouts/Wallet/services/dapps/types/SessionRequestResolved.qml @@ -47,6 +47,7 @@ QObject { /// maps to Constants.TransactionEstimatedTime values property int estimatedTimeCategory: 0 + signal expired() function isExpired() { return !!expirationTimestamp && expirationTimestamp > 0 && Math.floor(Date.now() / 1000) >= expirationTimestamp @@ -54,6 +55,7 @@ QObject { function setExpired() { expirationTimestamp = Math.floor(Date.now() / 1000) + expired() } function setActive() { diff --git a/ui/imports/shared/panels/AnimatedText.qml b/ui/imports/shared/panels/AnimatedText.qml index 6f8c443f4b..00e5b41cb4 100644 --- a/ui/imports/shared/panels/AnimatedText.qml +++ b/ui/imports/shared/panels/AnimatedText.qml @@ -6,6 +6,7 @@ SequentialAnimation { id: root property var target: null + property string targetProperty: "color" property color fromColor: Theme.palette.directColor1 property color toColor: Theme.palette.getColor(fromColor, 0.1) property int duration: 500 // in milliseconds @@ -14,7 +15,7 @@ SequentialAnimation { ColorAnimation { target: root.target - property: "color" + property: root.targetProperty from: root.fromColor to: root.toColor duration: root.duration @@ -22,7 +23,7 @@ SequentialAnimation { ColorAnimation { target: root.target - property: "color" + property: root.targetProperty from: root.toColor to: root.fromColor duration: root.duration diff --git a/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml b/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml index 3bf946f4bd..d535341e77 100644 --- a/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml +++ b/ui/imports/shared/popups/walletconnect/DAppSignRequestModal.qml @@ -38,6 +38,7 @@ SignTransactionModalBase { required property string cryptoFees required property string estimatedTime required property bool hasFees + required property bool estimatedTimeLoading property bool enoughFundsForTransaction: true property bool enoughFundsForFees: false @@ -98,15 +99,17 @@ SignTransactionModalBase { objectName: "footerFiatFeesText" text: formatBigNumber(root.fiatFees, root.currentCurrency) 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 value: qsTr("No fees") } + Binding on customColor { + value: !root.hasFees || root.enoughFundsForFees ? Theme.palette.directColor1 : Theme.palette.dangerColor1 + } onTextChanged: { - if (text === "") { + if (text === "" || loading) { return } maxFeesAnimation.restart() @@ -115,6 +118,8 @@ SignTransactionModalBase { AnimatedText { id: maxFeesAnimation target: maxFees + targetProperty: "customColor" + running: !maxFees.loading && root.hasFees fromColor: maxFees.customColor } } @@ -131,10 +136,10 @@ SignTransactionModalBase { id: estimatedTime objectName: "footerEstimatedTime" text: root.estimatedTime - loading: root.feesLoading + loading: root.estimatedTimeLoading onTextChanged: { - if (text === "") { + if (text === "" || loading) { return } estimatedTimeAnimation.restart() @@ -143,6 +148,8 @@ SignTransactionModalBase { AnimatedText { id: estimatedTimeAnimation target: estimatedTime + targetProperty: "customColor" + running: !estimatedTime.loading } } } @@ -203,10 +210,12 @@ SignTransactionModalBase { horizontalAlignment: Text.AlignRight font.pixelSize: Theme.additionalTextSize loading: root.feesLoading - customColor: root.enoughFundsForFees ? Theme.palette.directColor1 : Theme.palette.dangerColor1 + Binding on customColor { + value: root.enoughFundsForFees ? Theme.palette.directColor1 : Theme.palette.dangerColor1 + } onTextChanged: { - if (text === "") { + if (text === "" || loading) { return } fiatFeesAnimation.restart() @@ -215,6 +224,7 @@ SignTransactionModalBase { AnimatedText { id: fiatFeesAnimation target: fiatFees + targetProperty: "customColor" fromColor: fiatFees.customColor } } @@ -225,11 +235,13 @@ SignTransactionModalBase { text: formatBigNumber(root.cryptoFees, Constants.ethToken) horizontalAlignment: Text.AlignRight font.pixelSize: Theme.additionalTextSize - customColor: root.enoughFundsForFees ? Theme.palette.baseColor1 : Theme.palette.dangerColor1 loading: root.feesLoading + Binding on customColor { + value: root.enoughFundsForFees ? Theme.palette.baseColor1 : Theme.palette.dangerColor1 + } onTextChanged: { - if (text === "") { + if (text === "" || loading) { return } cryptoFeesAnimation.restart() @@ -239,6 +251,8 @@ SignTransactionModalBase { id: cryptoFeesAnimation target: cryptoFees fromColor: cryptoFees.customColor + targetProperty: "customColor" + running: !maxFees.loading && root.hasFees } } } diff --git a/ui/imports/shared/stores/DAppsStore.qml b/ui/imports/shared/stores/DAppsStore.qml index 0be4933e9c..84e5020722 100644 --- a/ui/imports/shared/stores/DAppsStore.qml +++ b/ui/imports/shared/stores/DAppsStore.qml @@ -14,6 +14,10 @@ QObject { signal signingResult(string topic, string id, string data) + signal estimatedTimeResponse(string topic, int timeCategory, bool success) + signal suggestedFeesResponse(string topic, var suggestedFeesJsonObj, bool success) + signal estimatedGasResponse(string topic, string gasEstimate, bool success) + function addWalletConnectSession(sessionJson) { return controller.addWalletConnectSession(sessionJson) } @@ -80,14 +84,24 @@ QObject { // Empty maxFeePerGas will fetch the current chain's maxFeePerGas // Returns ui/imports/utils -> Constants.TransactionEstimatedTime values - function getEstimatedTime(chainId, maxFeePerGasHex) { - return controller.getEstimatedTime(chainId, maxFeePerGasHex) + function requestEstimatedTime(topic, chainId, maxFeePerGasHex) { + controller.requestEstimatedTime(topic, chainId, maxFeePerGasHex) } // Returns nim's SuggestedFeesDto; see src/app_service/service/transaction/dto.nim // Returns all value initialized to 0 if error - function getSuggestedFees(chainId) { - return JSON.parse(controller.getSuggestedFeesJson(chainId)) + function requestSuggestedFees(topic, chainId) { + controller.requestSuggestedFeesJson(topic, chainId) + } + + function requestGasEstimate(topic, chainId, txObj) { + try { + let tx = prepareTxForStatusGo(txObj) + controller.requestGasEstimate(topic, chainId, JSON.stringify(tx)) + } catch (e) { + console.error("Failed to prepare tx for status-go", e) + root.estimatedGasResponse(topic, "", false) + } } function signTransaction(topic, id, address, chainId, password, txObj) { @@ -150,5 +164,24 @@ QObject { function onSigningResultReceived(topic, id, data) { root.signingResult(topic, id, data) } + + function onEstimatedTimeResponse(topic, timeCategory) { + root.estimatedTimeResponse(topic, timeCategory, !!timeCategory) + } + + function onSuggestedFeesResponse(topic, suggestedFeesJson) { + try { + const jsonObj = JSON.parse(suggestedFeesJson) + root.suggestedFeesResponse(topic, jsonObj, true) + } catch (e) { + console.error("Failed to parse suggestedFeesJson", e) + root.suggestedFeesResponse(topic, {}, false) + return + } + } + + function onEstimatedGasResponse(topic, gasEstimate) { + root.estimatedGasResponse(topic, gasEstimate, !!gasEstimate) + } } }