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/EstimatedTimeSubscription.qml b/ui/app/AppLayouts/Wallet/services/dapps/internal/EstimatedTimeSubscription.qml new file mode 100644 index 0000000000..22306b5a50 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/services/dapps/internal/EstimatedTimeSubscription.qml @@ -0,0 +1,8 @@ +import QtQuick 2.15 + +import StatusQ.Core.Utils 0.1 as SQUtils + +SQUtils.QObject { + id: root + +} \ No newline at end of file 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) + } } }