From a028a25cbe66dbb6a1151a2953f9fcd80b86b296 Mon Sep 17 00:00:00 2001 From: Sale Djenic Date: Tue, 21 Jan 2025 23:16:28 +0100 Subject: [PATCH] feat(wallet): transaction settings component added Closes #16193 #16194 --- storybook/pages/StatusInputPage.qml | 9 + .../pages/TransactionSettingsPanelPage.qml | 88 ++++ .../src/StatusQ/Controls/StatusInput.qml | 128 ++++- .../Wallet/popups/swap/SwapModalAdaptor.qml | 2 +- .../Wallet/views/TransactionSettings.qml | 495 ++++++++++++++++++ ui/app/AppLayouts/Wallet/views/qmldir | 1 + ui/imports/shared/popups/AlertPopup.qml | 28 +- ui/imports/utils/Constants.qml | 9 +- ui/imports/utils/Utils.qml | 20 + 9 files changed, 755 insertions(+), 25 deletions(-) create mode 100644 storybook/pages/TransactionSettingsPanelPage.qml create mode 100644 ui/app/AppLayouts/Wallet/views/TransactionSettings.qml diff --git a/storybook/pages/StatusInputPage.qml b/storybook/pages/StatusInputPage.qml index 46df3eb658..496f651aa3 100644 --- a/storybook/pages/StatusInputPage.qml +++ b/storybook/pages/StatusInputPage.qml @@ -3,6 +3,7 @@ import QtQuick.Controls 2.14 import QtQuick.Layouts 1.14 import StatusQ.Controls 0.1 +import StatusQ.Core.Theme 0.1 import Storybook 1.0 import Models 1.0 @@ -32,6 +33,14 @@ SplitView { enabled: enabledCheckBox.checked input.edit.readOnly: readOnlyCheckBox.checked input.clearable: clearableCheckBox.checked + label: "main label" + secondaryLabel: "secondary label" + labelIcon: "info" + labelIconColor: Theme.palette.baseColor1 + labelIconClickable: true + leftPadding: 10 + bottomLabelMessageRightCmp.text: "Current: 8.2 GWEI" + bottomLabelMessageLeftCmp.text: "0.0031 ETH" } } diff --git a/storybook/pages/TransactionSettingsPanelPage.qml b/storybook/pages/TransactionSettingsPanelPage.qml new file mode 100644 index 0000000000..141e56d8e8 --- /dev/null +++ b/storybook/pages/TransactionSettingsPanelPage.qml @@ -0,0 +1,88 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +import StatusQ.Controls 0.1 +import StatusQ.Core.Theme 0.1 + +import AppLayouts.Wallet.views 1.0 + +import utils 1.0 + +import Storybook 1.0 + +SplitView { + id: root + + SplitView { + SplitView.fillWidth: true + SplitView.fillHeight: true + + orientation: Qt.Vertical + + Rectangle { + SplitView.fillWidth: true + SplitView.fillHeight: true + color: Theme.palette.baseColor3 + + TransactionSettings { + id: txSettings + anchors.centerIn: parent + + currentBaseFee: "8.2" + currentSuggestedMinPriorityFee: "0.06" + currentSuggestedMaxPriorityFee: "5.1" + currentGasAmount: "31500" + currentNonce: 21 + + normalPrice: "1.45 EUR" + normalTime: "~60s" + fastPrice: "1.65 EUR" + fastTime: "~40s" + urgentPrice: "1.85 EUR" + urgentTime: "~15s" + + customBaseFee: "6.6" + customPriorityFee: "7.7" + customGasAmount: "35000" + customNonce: "22" + + selectedFeeMode: StatusFeeOption.Type.Normal + + fnGetPriceInCurrencyForFee: function(feeInWei) { + return "0.25 USD" + } + + onConfirmClicked: { + logs.logEvent("confirm clicked...") + logs.logEvent(`selected fee mode: ${txSettings.selectedFeeMode}`) + if (selectedFeeMode === StatusFeeOption.Type.Custom) { + logs.logEvent(`selected customBaseFee...${txSettings.customBaseFee}`) + logs.logEvent(`selected customPriorityFee...${txSettings.customPriorityFee}`) + logs.logEvent(`selected customGasAmount...${txSettings.customGasAmount}`) + logs.logEvent(`selected customNonce...${txSettings.customNonce}`) + } + } + } + } + + Logs { + id: logs + } + + LogsView { + clip: true + + SplitView.preferredHeight: 150 + SplitView.fillWidth: true + + logText: logs.logText + } + } + + Pane { + SplitView.preferredWidth: 300 + + } +} + +// category: Panel diff --git a/ui/StatusQ/src/StatusQ/Controls/StatusInput.qml b/ui/StatusQ/src/StatusQ/Controls/StatusInput.qml index bd4fc850c5..2983f96d3a 100644 --- a/ui/StatusQ/src/StatusQ/Controls/StatusInput.qml +++ b/ui/StatusQ/src/StatusQ/Controls/StatusInput.qml @@ -96,6 +96,42 @@ Item { \endqml */ property alias errorMessageCmp: errorMessage + /*! + \qmlproperty bottomLabelMessageLeftCmp + This property represents the bottomLabelMessageLeft shown on statusInput on the left of the input component. + By default this component is hidden and doesn't have any text set, once the text is set it will become visible. + Regardless of the set text and visibility bottomLabelMessageLeftCmp will be visible only if there is no error + meaning no errorMessageCmp visible. + + Examples of usage + + \qml + StatusInput { + bottomLabelMessageLeftCmp.text: "some text" + bottomLabelMessageLeftCmp.font.pixelSize: 15 + bottomLabelMessageLeftCmp.font.weight: Font.Medium + } + \endqml + */ + property alias bottomLabelMessageLeftCmp: bottomLabelMessageLeft + /*! + \qmlproperty bottomLabelMessageCmp + This property represents the bottomLabelMessageRight shown on statusInput on the right of the input component. + By default this component is hidden and doesn't have any text set, once the text is set it will become visible. + Regardless of the set text and visibility bottomLabelMessageRightCmp will be visible only if there is no error + meaning no errorMessageCmp visible. + + Examples of usage + + \qml + StatusInput { + bottomLabelMessageRightCmp.text: "some text" + bottomLabelMessageRightCmp.font.pixelSize: 15 + bottomLabelMessageRightCmp.font.weight: Font.Medium + } + \endqml + */ + property alias bottomLabelMessageRightCmp: bottomLabelMessageRight /*! \qmlproperty int StatusInput::labelPadding This property sets the padding of the label text. @@ -111,6 +147,21 @@ Item { This property sets the secondary label text. */ property string secondaryLabel: "" + /*! + \qmlproperty string StatusInput::labelIcon + This property sets the icon displayd on the right of the label. + */ + property string labelIcon: "" + /*! + \qmlproperty string StatusInput::labelIconColor + This property sets the color of the label icon. + */ + property string labelIconColor: Theme.palette.baseColor1 + /*! + \qmlproperty string StatusInput::labelIconClickable + This property sets if the label icon is clickable or not, if clickable labelIconClicked signal will be emitted. + */ + property bool labelIconClickable: false /*! \qmlproperty int StatusInput::charLimit This property sets the character limit of the text input. @@ -205,6 +256,11 @@ Item { This signal is emitted when the icon is clicked. */ signal iconClicked() + /*! + \qmlsignal + This signal is emitted when the label icon is clicked. + */ + signal labelIconClicked() /*! \qmlsignal This signal is emitted when a hard key is pressed passing as parameter the keyboard event. @@ -428,6 +484,23 @@ Item { color: Theme.palette.baseColor1 } + StatusIcon { + id: labelIcon + visible: !!root.labelIcon + width: 16 + height: 16 + icon: root.labelIcon + color: root.labelIconColor + + MouseArea { + anchors.fill: parent + enabled: root.labelIconClickable + hoverEnabled: root.labelIconClickable + cursorShape: containsMouse ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.labelIconClicked() + } + } + Item { Layout.fillWidth: true } @@ -467,24 +540,47 @@ Item { } } - StatusBaseText { - id: errorMessage - visible: { - if (!text) - return false; - - if ((root.validationMode === StatusInput.ValidationMode.OnlyWhenDirty && statusBaseInput.dirty) || - root.validationMode === StatusInput.ValidationMode.Always) - return !statusBaseInput.valid; - - return false; - } - font.pixelSize: 12 - color: Theme.palette.dangerColor1 - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignRight + RowLayout { + id: bottomRow Layout.topMargin: 8 Layout.fillWidth: true + Layout.preferredHeight: Math.max(bottomLabelMessageLeft.height, errorMessage.height, bottomLabelMessageLeft.height) + + StatusBaseText { + id: bottomLabelMessageLeft + Layout.fillWidth: true + visible: !errorMessage.visible && !!text + horizontalAlignment: Text.AlignLeft + font.pixelSize: 12 + elide: Text.ElideMiddle + } + + StatusBaseText { + id: errorMessage + Layout.fillWidth: true + visible: { + if (!text) + return false; + + if ((root.validationMode === StatusInput.ValidationMode.OnlyWhenDirty && statusBaseInput.dirty) || + root.validationMode === StatusInput.ValidationMode.Always) + return !statusBaseInput.valid; + + return false; + } + font.pixelSize: 12 + color: Theme.palette.dangerColor1 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignRight + } + + StatusBaseText { + id: bottomLabelMessageRight + visible: !errorMessage.visible && !!text + horizontalAlignment: Text.AlignRight + font.pixelSize: 12 + elide: Text.ElideMiddle + } } } } diff --git a/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml b/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml index a96ceac48d..58d4e1d8eb 100644 --- a/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml +++ b/ui/app/AppLayouts/Wallet/popups/swap/SwapModalAdaptor.qml @@ -146,7 +146,7 @@ QObject { const totalMaxFees = Math.ceil(bestPath.gasFees.maxFeePerGasM) * bestPath.gasAmount const totalMaxFeesInEth = AmountsArithmetic.div( AmountsArithmetic.fromString(totalMaxFees), - AmountsArithmetic.fromNumber(1, Constants.gweiExponent)) + AmountsArithmetic.fromNumber(1, Constants.ethTokenGWeiDecimals)) root.swapOutputData.maxFeesToReserveRaw = AmountsArithmetic.times(totalMaxFeesInEth, AmountsArithmetic.fromExponent(Constants.ethTokenDecimals)).toString() root.swapOutputData.approvalNeeded = !!bestPath ? bestPath.approvalRequired: false diff --git a/ui/app/AppLayouts/Wallet/views/TransactionSettings.qml b/ui/app/AppLayouts/Wallet/views/TransactionSettings.qml new file mode 100644 index 0000000000..8d74c9aaa1 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/views/TransactionSettings.qml @@ -0,0 +1,495 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 + +import StatusQ.Core 0.1 +import StatusQ.Controls 0.1 +import StatusQ.Controls.Validators 0.1 +import StatusQ.Core.Utils 0.1 as SQUtils +import StatusQ.Core.Theme 0.1 + +import shared.controls 1.0 +import shared.popups 1.0 +import utils 1.0 + +Rectangle { + id: root + + required property string currentBaseFee + required property string currentSuggestedMinPriorityFee + required property string currentSuggestedMaxPriorityFee + required property string currentGasAmount + required property int currentNonce + + property alias normalPrice: optionNormal.subText + property alias normalTime: optionNormal.additionalText + + property alias fastPrice: optionFast.subText + property alias fastTime: optionFast.additionalText + + property alias urgentPrice: optionUrgent.subText + property alias urgentTime: optionUrgent.additionalText + + property alias customBaseFee: customBaseFeeInput.text + property alias customBaseFeeDirty: customBaseFeeInput.input.dirty + property alias customPriorityFee: customPriorityFeeInput.text + property alias customPriorityFeeDirty: customPriorityFeeInput.input.dirty + property alias customGasAmount: customGasAmountInput.text + property alias customGasAmountDirty: customGasAmountInput.input.dirty + property alias customNonce: customNonceInput.text + property alias customNonceDirty: customNonceInput.input.dirty + + required property int selectedFeeMode + + required property var fnGetPriceInCurrencyForFee + + signal confirmClicked() + signal cancelClicked() + + color: Theme.palette.statusModal.backgroundColor + radius: 8 + + implicitHeight: layout.implicitHeight + implicitWidth: layout.implicitWidth + + focus: true + + Keys.onReleased: { + if (event.key === Qt.Key_Escape) { + root.cancelClicked() + } + } + + function recalculateCustomPrice() { + d.recalculateCustomPrice() + } + + Component.onCompleted: root.forceActiveFocus() + + QtObject { + id: d + + readonly property bool customMode: root.selectedFeeMode === StatusFeeOption.Type.Custom + + function showAlert(title, text, note, url) { + infoBox.title = title + infoBox.text = text + infoBox.note = note + infoBox.url = url + infoBox.active = true + } + + function recalculateCustomBaseFeePrice() { + if (!customBaseFeeInput.text) { + customBaseFeeInput.bottomLabelMessageRightCmp.text = "" + return + } + const weiValue = Utils.gweiToWei(customBaseFeeInput.text).toFixed() + customBaseFeeInput.bottomLabelMessageRightCmp.text = root.fnGetPriceInCurrencyForFee(weiValue) + } + + function recalculateCustomPriorityFeePrice() { + if (!customPriorityFeeInput.text) { + customPriorityFeeInput.bottomLabelMessageRightCmp.text = "" + return + } + const weiValue = Utils.gweiToWei(customPriorityFeeInput.text).toFixed() + customPriorityFeeInput.bottomLabelMessageRightCmp.text = root.fnGetPriceInCurrencyForFee(weiValue) + } + + function recalculateCustomPrice() { + if (!customBaseFeeInput.text || !customPriorityFeeInput.text || !customGasAmountInput.text) { + optionCustom.subText = "" + return + } + const baseFeeWei = Utils.gweiToWei(customBaseFeeInput.text) + const priorityFeeWei = Utils.gweiToWei(customPriorityFeeInput.text) + const totalFee = SQUtils.AmountsArithmetic.sum(baseFeeWei, priorityFeeWei) + const feeInWei = SQUtils.AmountsArithmetic.times(totalFee, SQUtils.AmountsArithmetic.fromString(customGasAmountInput.text)).toFixed() + optionCustom.subText = root.fnGetPriceInCurrencyForFee(feeInWei) + } + } + + Loader { + id: infoBox + anchors.centerIn: root + active: false + + property string title + property string text + property string note + property string url + + sourceComponent: AlertPopup { + title: infoBox.title + + width: root.width - 2 * 20 + + acceptBtnText: qsTr("Got it") + cancelBtn.text: !!infoBox.url? qsTr("Read more") : "" + cancelBtn.icon.name: "external-link" + cancelBtn.visible: !!infoBox.url + + alertLabel.text: infoBox.text + alertNote.visible: !!infoBox.note + alertNote.text: infoBox.note + alertNote.color: Theme.palette.baseColor1 + + onCancelClicked: { + Qt.openUrlExternally(infoBox.url) + } + + onClosed: { + infoBox.active = false + } + } + + onLoaded: { + infoBox.item.open() + } + } + + ColumnLayout { + id: layout + + ColumnLayout { + Layout.margins: 20 + + spacing: 16 + + StatusBaseText { + Layout.preferredWidth: parent.width + text: qsTr("Transaction settings") + font.pixelSize: 17 + font.bold: true + elide: Text.ElideMiddle + } + + RowLayout { + id: options + spacing: 12 + + StatusFeeOption { + id: optionNormal + type: StatusFeeOption.Type.Normal + selected: root.selectedFeeMode === StatusFeeOption.Type.Normal + showSubText: true +// showAdditionalText: true // TODO: temoporary disabled until we figure out how to estimate time more granularly + + onClicked: root.selectedFeeMode = StatusFeeOption.Type.Normal + } + + StatusFeeOption { + id: optionFast + type: StatusFeeOption.Type.Fast + selected: root.selectedFeeMode === StatusFeeOption.Type.Fast + showSubText: true +// showAdditionalText: true // TODO: temoporary disabled until we figure out how to estimate time more granularly + + onClicked: root.selectedFeeMode = StatusFeeOption.Type.Fast + } + + StatusFeeOption { + id: optionUrgent + type: StatusFeeOption.Type.Urgent + selected: root.selectedFeeMode === StatusFeeOption.Type.Urgent + showSubText: true +// showAdditionalText: true // TODO: temoporary disabled until we figure out how to estimate time more granularly + + onClicked: root.selectedFeeMode = StatusFeeOption.Type.Urgent + } + + StatusFeeOption { + id: optionCustom + type: StatusFeeOption.Type.Custom + selected: root.selectedFeeMode === StatusFeeOption.Type.Custom + showSubText: !!selected +// showAdditionalText: !!selected // TODO: temoporary disabled until we figure out how to estimate time more granularly + unselectedText: "Set your own fees & nonce" + + onClicked: root.selectedFeeMode = StatusFeeOption.Type.Custom + } + } + + StatusBaseText { + Layout.preferredWidth: parent.width + visible: !d.customMode + text: qsTr("Increased base and priority fee, incentivising miners to confirm more quickly") + color: Theme.palette.baseColor1 + font.pixelSize: Theme.tertiaryTextFontSize + elide: Text.ElideMiddle + } + + ShapeRectangle { + Layout.preferredWidth: parent.width + Layout.preferredHeight: customLayout.height + customLayout.anchors.margins + visible: d.customMode + + ColumnLayout { + id: customLayout + anchors.left: parent.left + anchors.margins: 20 + width: parent.width - 2 * anchors.margins + spacing: 16 + + StatusInput { + id: customBaseFeeInput + + readonly property bool displayLowBaseFeeWarning: { + if (!customBaseFeeInput.text) { + return + } + const weiCurrentValue = SQUtils.AmountsArithmetic.fromString(root.currentBaseFee) + const decreasedCurrentValue = SQUtils.AmountsArithmetic.times(weiCurrentValue, SQUtils.AmountsArithmetic.fromString("0.9")) // up to -10% is acceptable + const weiEnteredValue = Utils.gweiToWei(customBaseFeeInput.text) + return decreasedCurrentValue.cmp(weiEnteredValue) === 1 + } + + readonly property bool displayHighBaseFeeWarning: { + if (!customBaseFeeInput.text) { + return + } + const weiCurrentValue = SQUtils.AmountsArithmetic.fromString(root.currentBaseFee) + const increasedCurrentValue = SQUtils.AmountsArithmetic.times(weiCurrentValue, SQUtils.AmountsArithmetic.fromString("1.2")) // up to 20% higher value is acceptable + const weiEnteredValue = Utils.gweiToWei(customBaseFeeInput.text) + return weiEnteredValue.cmp(increasedCurrentValue) === 1 + } + + Layout.preferredWidth: parent.width + Layout.topMargin: 20 + label: qsTr("Max base fee") + labelIcon: "info" + labelIconColor: Theme.palette.baseColor1 + labelIconClickable: true + bottomLabelMessageLeftCmp.color: customBaseFeeInput.displayLowBaseFeeWarning? + Theme.palette.dangerColor1 + :customBaseFeeInput.displayHighBaseFeeWarning? + Theme.palette.miscColor6 + : Theme.palette.baseColor1 + bottomLabelMessageLeftCmp.text: customBaseFeeInput.displayLowBaseFeeWarning? + qsTr("Lower than necessary (current %1)").arg(Utils.weiToGWei(root.currentBaseFee)) + : customBaseFeeInput.displayHighBaseFeeWarning? + qsTr("Higher than necessary (current %1)").arg(Utils.weiToGWei(root.currentBaseFee)) + : qsTr("Current: %1 GWEI").arg(Utils.weiToGWei(root.currentBaseFee)) + rightPadding: leftPadding + input.rightComponent: StatusBaseText { + text: "GWEI" + color: Theme.palette.baseColor1 + } + + validators: [ + StatusRegularExpressionValidator { + regularExpression: Constants.regularExpressions.positiveRealNumbers + errorMessage: Constants.errorMessages.positiveRealNumbers + } + ] + + onTextChanged: Qt.callLater(() => { + if (!customBaseFeeInput.valid) { + return + } + d.recalculateCustomBaseFeePrice() + d.recalculateCustomPrice() + }) + + onLabelIconClicked: d.showAlert(label, + qsTr("When your transaction gets included in the block, any difference between your max base fee and the actual base fee will be refunded.\n"), + qsTr("Note: the ETH amount shown for this value is calculated:\nMax base fee (in GWEI) * Max gas amount"), + "") + } + + StatusInput { + id: customPriorityFeeInput + + readonly property bool displayHigherPriorityFeeWarning: { + if (!customPriorityFeeInput.text) { + return false + } + const weiCurrentValue = SQUtils.AmountsArithmetic.fromString(root.currentSuggestedMaxPriorityFee) + const weiEnteredValue = Utils.gweiToWei(customPriorityFeeInput.text) + return weiEnteredValue.cmp(weiCurrentValue) === 1 + } + + readonly property bool displayHigherThanBaseFeeWarning: { + if (!customPriorityFeeInput.text || !customBaseFeeInput.text) { + return false + } + const weiBaseFeeValue = Utils.gweiToWei(customBaseFeeInput.text) + const weiEnteredValue = Utils.gweiToWei(customPriorityFeeInput.text) + return weiEnteredValue.cmp(weiBaseFeeValue) === 1 + } + + Layout.preferredWidth: parent.width + label: qsTr("Priority fee") + labelIcon: "info" + labelIconColor: Theme.palette.baseColor1 + labelIconClickable: true + bottomLabelMessageLeftCmp.color: customPriorityFeeInput.displayHigherThanBaseFeeWarning? + Theme.palette.dangerColor1 + : customPriorityFeeInput.displayHigherPriorityFeeWarning? + Theme.palette.miscColor6 + : Theme.palette.baseColor1 + bottomLabelMessageLeftCmp.text: customPriorityFeeInput.displayHigherThanBaseFeeWarning? + qsTr("Higher than max base fee: %1 GWEI").arg(customBaseFeeInput.text) + : customPriorityFeeInput.displayHigherPriorityFeeWarning? + qsTr("Higher than necessary (current %1 - %2)").arg(Utils.weiToGWei(root.currentSuggestedMinPriorityFee)).arg(Utils.weiToGWei(root.currentSuggestedMaxPriorityFee)) + : qsTr("Current: %1 - %2 GWEI").arg(Utils.weiToGWei(root.currentSuggestedMinPriorityFee)).arg(Utils.weiToGWei(root.currentSuggestedMaxPriorityFee)) + rightPadding: leftPadding + input.rightComponent: StatusBaseText { + text: "GWEI" + color: Theme.palette.baseColor1 + } + + validators: [ + StatusRegularExpressionValidator { + regularExpression: Constants.regularExpressions.positiveRealNumbers + errorMessage: Constants.errorMessages.positiveRealNumbers + } + ] + + onTextChanged: Qt.callLater(() => { + if (!customPriorityFeeInput.valid) { + return + } + d.recalculateCustomPriorityFeePrice() + d.recalculateCustomPrice() + }) + + onLabelIconClicked: d.showAlert(label, + qsTr("AKA miner tip. A voluntary fee you can add to incentivise miners or validators to prioritise your transaction.\n\nThe higher the tip, the faster your transaction is likely to be processed, especially curing periods of higher network congestion.\n"), + qsTr("Note: the ETH amount shown for this value is calculated: Priority fee (in GWEI) * Max gas amount"), + "") + } + + StatusInput { + id: customGasAmountInput + + property string minAmount + property string maxAmount + + readonly property bool displayTooLowAmountWarning: { + if (!customGasAmountInput.text) { + return false + } + const currentValue = SQUtils.AmountsArithmetic.fromString(root.currentGasAmount) + const decreasedCurrentValue = SQUtils.AmountsArithmetic.times(currentValue, SQUtils.AmountsArithmetic.fromString("0.9")) // up to -10% is acceptable + const enteredValue = SQUtils.AmountsArithmetic.fromString(customGasAmountInput.text) + customGasAmountInput.minAmount = decreasedCurrentValue.toFixed() + return decreasedCurrentValue.cmp(enteredValue) === 1 + } + + readonly property bool displayTooHighAmountWarning: { + if (!customGasAmountInput.text) { + return false + } + const currentValue = SQUtils.AmountsArithmetic.fromString(root.currentGasAmount) + const increasedCurrentValue = SQUtils.AmountsArithmetic.times(currentValue, SQUtils.AmountsArithmetic.fromString("1.2")) // up to 20% higher value is acceptable + const enteredValue = SQUtils.AmountsArithmetic.fromString(customGasAmountInput.text) + customGasAmountInput.maxAmount = increasedCurrentValue.toFixed() + return enteredValue.cmp(increasedCurrentValue) === 1 + } + + Layout.preferredWidth: parent.width + label: qsTr("Max gas amount") + labelIcon: "info" + labelIconColor: Theme.palette.baseColor1 + labelIconClickable: true + bottomLabelMessageLeftCmp.color: customGasAmountInput.displayTooLowAmountWarning? + Theme.palette.dangerColor1 + : customGasAmountInput.displayTooHighAmountWarning? + Theme.palette.miscColor6 + : Theme.palette.baseColor1 + bottomLabelMessageLeftCmp.text: customGasAmountInput.displayTooLowAmountWarning? + qsTr("Too low (should be %1 or more)").arg(customGasAmountInput.minAmount) + : customGasAmountInput.displayTooHighAmountWarning? + qsTr("Too high (should be less than %1)").arg(customGasAmountInput.maxAmount) + : qsTr("Current: %1").arg(root.currentGasAmount) + rightPadding: leftPadding + input.rightComponent: StatusBaseText { + text: "UNITS" + color: Theme.palette.baseColor1 + } + + validators: [ + StatusRegularExpressionValidator { + regularExpression: Constants.regularExpressions.wholeNumbers + errorMessage: Constants.errorMessages.wholeNumbers + } + ] + + onTextChanged: Qt.callLater(() => { + if (!customGasAmountInput.valid) { + return + } + d.recalculateCustomPrice() + }) + + onLabelIconClicked: d.showAlert(qsTr("Gas amount"), + qsTr("AKA gas limit. Refers to the maximum number of computational steps (or units of gas) that a transaction can consume. It represents the complexity or amount of work required to execute a transaction or smart contract.\n\nThe gas limit is a cap on how much work the transaction can do on the blockchain. If the gas limit is set too low, the transaction may fail due to insufficient gas."), + "", + "") + } + + StatusInput { + id: customNonceInput + + readonly property bool displayHighNonceWarning: { + if (!customNonceInput.text) { + return false + } + + try { + const expectedValue = parseInt(root.currentNonce) + const enteredValue = parseInt(customNonceInput.text) + return enteredValue > expectedValue + + } catch (e) { + return false + } + } + + Layout.preferredWidth: parent.width + label: qsTr("Nonce") + labelIcon: "info" + labelIconColor: Theme.palette.baseColor1 + labelIconClickable: true + bottomLabelMessageLeftCmp.color: customNonceInput.displayHighNonceWarning? + Theme.palette.miscColor6 + : Theme.palette.baseColor1 + bottomLabelMessageLeftCmp.text: { + if (customNonceInput.displayHighNonceWarning) { + return qsTr("Higher than suggested nonce of %1").arg(root.currentNonce) + } + let lastUsedNonce = root.currentNonce - 1 + if (lastUsedNonce < 0) { + lastUsedNonce = "-" + } + return qsTr("Last transaction: %1").arg(lastUsedNonce) + } + rightPadding: leftPadding + input.leftComponent: input.rightComponent // for the reference to the `input` + + validators: [ + StatusRegularExpressionValidator { + regularExpression: Constants.regularExpressions.wholeNumbers + errorMessage: Constants.errorMessages.wholeNumbers + } + ] + + onLabelIconClicked: d.showAlert(label, + qsTr("Transaction counter ensuring transactions from your account are processed in the correct order and can’t be replayed. Each new transaction increments the nonce by 1, ensuring uniqueness and preventing double-spending.\n\nIf a transaction with a lower nonce is pending, higher nonce transactions will remain in the queue until the earlier one is confirmed."), + "", + "") + } + } + } + + StatusButton { + Layout.preferredWidth: parent.width + enabled: !d.customMode || customBaseFeeInput.valid && customPriorityFeeInput.valid && customGasAmountInput.valid && customNonceInput.valid + text: qsTr("Confirm") + onClicked: root.confirmClicked() + } + } + } +} diff --git a/ui/app/AppLayouts/Wallet/views/qmldir b/ui/app/AppLayouts/Wallet/views/qmldir index 12ef9ae06c..aa6fae11b2 100644 --- a/ui/app/AppLayouts/Wallet/views/qmldir +++ b/ui/app/AppLayouts/Wallet/views/qmldir @@ -8,3 +8,4 @@ TokenSelectorSectionDelegate 1.0 TokenSelectorSectionDelegate.qml AccountContextMenu 1.0 AccountContextMenu.qml RecipientView 1.0 RecipientView.qml SendModalFooter 1.0 SendModalFooter.qml +TransactionSettings 1.0 TransactionSettings.qml \ No newline at end of file diff --git a/ui/imports/shared/popups/AlertPopup.qml b/ui/imports/shared/popups/AlertPopup.qml index 5930996bb9..b539228561 100644 --- a/ui/imports/shared/popups/AlertPopup.qml +++ b/ui/imports/shared/popups/AlertPopup.qml @@ -15,8 +15,11 @@ StatusDialog { id: root property alias acceptBtnText: acceptBtn.text + property alias acceptBtn: acceptBtn + property alias cancelBtn: cancelBtn property alias alertText: contentTextItem.text property alias alertLabel: contentTextItem + property alias alertNote: contentNoteItem property int acceptBtnType: StatusBaseButton.Type.Danger property StatusAssetSettings asset: StatusAssetSettings { @@ -36,12 +39,22 @@ StatusDialog { implicitWidth: 400 // by design topPadding: Theme.padding bottomPadding: topPadding - contentItem: StatusBaseText { - id: contentTextItem - - font.pixelSize: Theme.primaryTextFontSize - wrapMode: Text.WordWrap - lineHeight: 1.2 + contentItem: Column { + StatusBaseText { + id: contentTextItem + width: parent.width + font.pixelSize: Theme.primaryTextFontSize + wrapMode: Text.WordWrap + lineHeight: 1.2 + } + StatusBaseText { + id: contentNoteItem + visible: false + width: parent.width + font.pixelSize: Theme.primaryTextFontSize + wrapMode: Text.WordWrap + lineHeight: 1.2 + } } header: StatusDialogHeader { @@ -61,6 +74,7 @@ StatusDialog { rightButtons: ObjectModel { StatusButton { + id: cancelBtn text: qsTr("Cancel") normalColor: "transparent" @@ -75,6 +89,8 @@ StatusDialog { type: root.acceptBtnType + Component.onCompleted: acceptBtn.forceActiveFocus() + onClicked: { root.acceptClicked() close() diff --git a/ui/imports/utils/Constants.qml b/ui/imports/utils/Constants.qml index b892205778..06e1ea23ed 100644 --- a/ui/imports/utils/Constants.qml +++ b/ui/imports/utils/Constants.qml @@ -664,6 +664,8 @@ QtObject { readonly property var numerical: /^$|^[0-9]+$/ readonly property var emoji: /\ud83c\udff4(\udb40[\udc61-\udc7a])+\udb40\udc7f|(\ud83c[\udde6-\uddff]){2}|([\#\*0-9]\ufe0f?\u20e3)|(\u00a9|\u00ae|[\u203c\u2049\u20e3\u2122\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23e9-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u261d\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u265f\u2660\u2663\u2665\u2666\u2668\u267b\u267e\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26ce\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299]|\ud83c[\udc04\udccf\udd70\udd71\udd7e\udd7f\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude02\ude1a\ude2f\ude32-\ude3a\ude50\ude51\udf00-\udf21\udf24-\udf93\udf96\udf97\udf99-\udf9b\udf9e-\udff0\udff3-\udff5\udff7-\udfff]|\ud83d[\udc00-\udcfd\udcff-\udd3d\udd49-\udd4e\udd50-\udd67\udd6f\udd70\udd73-\udd7a\udd87\udd8a-\udd8d\udd90\udd95\udd96\udda4\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa-\ude4f\ude80-\udec5\udecb-\uded2\uded5-\uded7\udedc-\udee5\udee9\udeeb\udeec\udef0\udef3-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0c-\udd3a\udd3c-\udd45\udd47-\ude7c\ude80-\ude88\ude90-\udebd\udebf-\udec5\udece-\udedb\udee0-\udee8\udef0-\udef8])((\ud83c[\udffb-\udfff])?(\ud83e[\uddb0-\uddb3])?(\ufe0f?\u200d([\u2000-\u3300]|[\ud83c-\ud83e][\ud000-\udfff])\ufe0f?)?)*/g; readonly property var asciiWithEmoji: /^[\u00a9\u00ae\u2000-\u3300\ud83c\ud000-\udfff\ud83d\ud000-\udfff\ud83e\ud000-\udfff\u0000-\u007F]+$/ + readonly property var wholeNumbers: /^(0|[1-9][0-9]*)$/ + readonly property var positiveRealNumbers: /^(0|[1-9][0-9]*)([.,][0-9]+)?$/ } readonly property QtObject errorMessages: QtObject { @@ -673,6 +675,8 @@ QtObject { readonly property string alphanumericalWithSpaceRegExp: qsTr("Special characters are not allowed") readonly property string asciiRegExp: qsTr("Only letters, numbers and ASCII characters allowed") readonly property string emojRegExp: qsTr("Name is too cool (use A-Z and 0-9, single whitespace, hyphens and underscores only)") + readonly property var wholeNumbers: qsTr("Whole numbers only") + readonly property var positiveRealNumbers: qsTr("Positive real numbers only") } readonly property QtObject socialLinkType: QtObject { @@ -892,8 +896,9 @@ QtObject { readonly property string networkRopsten: "Ropsten" readonly property string ethToken: "ETH" - readonly property int ethTokenDecimals: 18 - readonly property int gweiExponent: 9 + + readonly property int ethTokenWeiDecimals: 18 + readonly property int ethTokenGWeiDecimals: 9 readonly property QtObject networkShortChainNames: QtObject { readonly property string mainnet: "eth" diff --git a/ui/imports/utils/Utils.qml b/ui/imports/utils/Utils.qml index b2bff0c00b..09918bac69 100644 --- a/ui/imports/utils/Utils.qml +++ b/ui/imports/utils/Utils.qml @@ -980,4 +980,24 @@ QtObject { return typeName } + + function weiToEth(value) { + return StatusQUtils.AmountsArithmetic.div(StatusQUtils.AmountsArithmetic.fromString(value), + StatusQUtils.AmountsArithmetic.fromNumber(1, Constants.ethTokenWeiDecimals)) + } + + function ethToWei(value) { + return StatusQUtils.AmountsArithmetic.times(StatusQUtils.AmountsArithmetic.fromString(value), + StatusQUtils.AmountsArithmetic.fromNumber(1, Constants.ethTokenWeiDecimals)) + } + + function weiToGWei(value) { + return StatusQUtils.AmountsArithmetic.div(StatusQUtils.AmountsArithmetic.fromString(value), + StatusQUtils.AmountsArithmetic.fromNumber(1, Constants.ethTokenGWeiDecimals)) + } + + function gweiToWei(value) { + return StatusQUtils.AmountsArithmetic.times(StatusQUtils.AmountsArithmetic.fromString(value), + StatusQUtils.AmountsArithmetic.fromNumber(1, Constants.ethTokenGWeiDecimals)) + } }